mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-19 17:29:13 +00:00
Compare commits
260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56392b87f7 | ||
|
|
1b1a4aeb38 | ||
|
|
16147e0c08 | ||
|
|
139317cf1b | ||
|
|
72b94127fb | ||
|
|
1f1fc94d22 | ||
|
|
a574fe026c | ||
|
|
aa82083d30 | ||
|
|
08d5df70c2 | ||
|
|
29b8fa5897 | ||
|
|
e96faf31d4 | ||
|
|
157a73aa99 | ||
|
|
bdd298c8a0 | ||
|
|
3f7dd21186 | ||
|
|
086b708cf7 | ||
|
|
57e0e57f48 | ||
|
|
4b7efbfdc0 | ||
|
|
7dc2653042 | ||
|
|
e428453835 | ||
|
|
f84c8229de | ||
|
|
a73427d68d | ||
|
|
e4456bb236 | ||
|
|
06eadd0c15 | ||
|
|
3c90dfa660 | ||
|
|
ace1b8ee71 | ||
|
|
676356e800 | ||
|
|
f732e54c22 | ||
|
|
cdc2e74f68 | ||
|
|
724f3e872b | ||
|
|
d63e5165eb | ||
|
|
9892c4392e | ||
|
|
5ced1a775c | ||
|
|
761de1318e | ||
|
|
02508512d5 | ||
|
|
6e6105af05 | ||
|
|
d569419e13 | ||
|
|
93f1641803 | ||
|
|
ff52bf93fa | ||
|
|
a039275a0c | ||
|
|
a98d10104d | ||
|
|
8924bc59b1 | ||
|
|
eefe60a9c9 | ||
|
|
fe1cb3d904 | ||
|
|
0448278a78 | ||
|
|
99c0c2ff4c | ||
|
|
b369b734ca | ||
|
|
57150a20fd | ||
|
|
1634d7d531 | ||
|
|
d563de4207 | ||
|
|
5cd4b82ed0 | ||
|
|
5f728d348c | ||
|
|
596c4b6e40 | ||
|
|
36d1e7c44a | ||
|
|
25c17082f2 | ||
|
|
810ccf8e94 | ||
|
|
c8ed0b19f0 | ||
|
|
9e09444c65 | ||
|
|
5923fa0cd5 | ||
|
|
b2d4c5d14b | ||
|
|
0bb9c1d650 | ||
|
|
fbfa3abffd | ||
|
|
b5656aa5dd | ||
|
|
d53fd6a109 | ||
|
|
b0650b926b | ||
|
|
845f6a0a93 | ||
|
|
d8daa83c79 | ||
|
|
7bb0199e83 | ||
|
|
f014dadf06 | ||
|
|
393e54ce91 | ||
|
|
fdf4ad9543 | ||
|
|
5f0d384c9e | ||
|
|
4271700046 | ||
|
|
e153b0ab78 | ||
|
|
26868ae668 | ||
|
|
17c0364eda | ||
|
|
b28ac7af8c | ||
|
|
2dcaa21a44 | ||
|
|
33cc8363f9 | ||
|
|
9b61e1c85c | ||
|
|
6f53fdc02d | ||
|
|
6f850f5a55 | ||
|
|
a482a4b1f4 | ||
|
|
3664e6f96d | ||
|
|
dda8808173 | ||
|
|
63a24c23cc | ||
|
|
1ec3a72f79 | ||
|
|
566285ec0e | ||
|
|
d5ba82338d | ||
|
|
cbecd2a2fc | ||
|
|
3772dd40ac | ||
|
|
f69a0f0261 | ||
|
|
cb323ffb84 | ||
|
|
0db73e71a0 | ||
|
|
eeb0c838db | ||
|
|
dc48ee5aed | ||
|
|
c0acfa57a9 | ||
|
|
3e166ef927 | ||
|
|
4942d83de5 | ||
|
|
4c30b39e71 | ||
|
|
e55f4fe6b6 | ||
|
|
aff74cffa0 | ||
|
|
8b29bb8664 | ||
|
|
3cee57b6c2 | ||
|
|
857f4a4fc8 | ||
|
|
a942293a74 | ||
|
|
550b121990 | ||
|
|
cc84901a49 | ||
|
|
9d3764c5d9 | ||
|
|
0950235ccd | ||
|
|
8ed7fc894e | ||
|
|
e504ffa225 | ||
|
|
9c63b37bb4 | ||
|
|
5c110ca359 | ||
|
|
1ab61beeb9 | ||
|
|
8e45a546c9 | ||
|
|
745a7f76ea | ||
|
|
8cb9ab3204 | ||
|
|
12533d1414 | ||
|
|
bd1c164d57 | ||
|
|
7446c2096d | ||
|
|
8ce5c4b885 | ||
|
|
ab76112f5f | ||
|
|
9c54e39eae | ||
|
|
61eab44474 | ||
|
|
f6285ec710 | ||
|
|
ed878ec4b4 | ||
|
|
e38d41d67a | ||
|
|
3d237d72bd | ||
|
|
8044d2390c | ||
|
|
6b82e6b5ac | ||
|
|
842e6a93e2 | ||
|
|
f140f054e5 | ||
|
|
5cd4726e23 | ||
|
|
bccc58d693 | ||
|
|
e25f1c1481 | ||
|
|
fc4e690996 | ||
|
|
dadb2f9d37 | ||
|
|
5bf15b0587 | ||
|
|
5f9c0c3204 | ||
|
|
dfa4f0c309 | ||
|
|
f0063b4b0d | ||
|
|
5dc51c34ea | ||
|
|
5bf7a55bfa | ||
|
|
eb9ae8d5dc | ||
|
|
2a133587cc | ||
|
|
0e4a19c368 | ||
|
|
813c820227 | ||
|
|
870cee5707 | ||
|
|
4e55d2d941 | ||
|
|
8e962bf992 | ||
|
|
0815715f7b | ||
|
|
85e4697b7f | ||
|
|
16fdb9bf4c | ||
|
|
46f3d50a54 | ||
|
|
3a38240fb2 | ||
|
|
662f0b8fb6 | ||
|
|
96ce42ae91 | ||
|
|
93f587b851 | ||
|
|
89a940ec81 | ||
|
|
a33771b15d | ||
|
|
9a566e5559 | ||
|
|
6e75d42a92 | ||
|
|
575413cac9 | ||
|
|
6a9476c6d0 | ||
|
|
5468f1705c | ||
|
|
5ea132e712 | ||
|
|
8128fcf8bc | ||
|
|
e89655f793 | ||
|
|
2db2b068c4 | ||
|
|
a59e214317 | ||
|
|
ae2b6e4d7a | ||
|
|
b10fc6a0b0 | ||
|
|
70977e5228 | ||
|
|
4482391574 | ||
|
|
bd078fc883 | ||
|
|
644af87782 | ||
|
|
1ce36c1069 | ||
|
|
0a71005ecc | ||
|
|
698618a4b3 | ||
|
|
f9642dd79f | ||
|
|
85d1a3c016 | ||
|
|
38c74c81a6 | ||
|
|
4c04991b70 | ||
|
|
293a339fed | ||
|
|
5255a527f9 | ||
|
|
9440dfb66c | ||
|
|
7a019eee19 | ||
|
|
93f56a5dc8 | ||
|
|
68264228b8 | ||
|
|
66c1b8e26c | ||
|
|
5776c048ea | ||
|
|
76dd09bc50 | ||
|
|
73d18d3abd | ||
|
|
c1c9d0c8a3 | ||
|
|
64420ead7c | ||
|
|
6d035c6888 | ||
|
|
833ca8cce9 | ||
|
|
d02d506b13 | ||
|
|
f306056e5d | ||
|
|
58ec669d15 | ||
|
|
d1b61bfed3 | ||
|
|
325e0c6781 | ||
|
|
8d66cd52b5 | ||
|
|
4b9277629c | ||
|
|
6515a6188b | ||
|
|
8b3ca52502 | ||
|
|
fae003e085 | ||
|
|
4b961d2d8f | ||
|
|
e27fc512b4 | ||
|
|
8f0f600b6b | ||
|
|
5950610690 | ||
|
|
fce3df0c82 | ||
|
|
e2021231c6 | ||
|
|
f61dd7509e | ||
|
|
db2b64e58c | ||
|
|
d70999c386 | ||
|
|
eb6ecc59ab | ||
|
|
1e0e2fadfd | ||
|
|
4325f714b9 | ||
|
|
137cd45497 | ||
|
|
f3dbe4416f | ||
|
|
7fb55c0f51 | ||
|
|
fdc6cbc507 | ||
|
|
072085ae82 | ||
|
|
04a8996348 | ||
|
|
c26dcc2618 | ||
|
|
a4dc340bbc | ||
|
|
3c069fb588 | ||
|
|
1fe38f5ed1 | ||
|
|
841c9424e9 | ||
|
|
9c44a0c7d3 | ||
|
|
2883d2eb31 | ||
|
|
f5aade943e | ||
|
|
d17c3f39d0 | ||
|
|
9ac9ace6b8 | ||
|
|
c9d2cef58d | ||
|
|
a9e30eefdc | ||
|
|
1a895db9bd | ||
|
|
a955bc3b9b | ||
|
|
96e888a4f5 | ||
|
|
99ff0c1e3c | ||
|
|
599e89b1f9 | ||
|
|
33c527f15e | ||
|
|
eb02dacfdc | ||
|
|
e6a0e5b858 | ||
|
|
545ba80697 | ||
|
|
1e250ee95c | ||
|
|
5a12eedc2c | ||
|
|
5605fde777 | ||
|
|
9ac142688a | ||
|
|
2791790bf5 | ||
|
|
1752972be9 | ||
|
|
c877aba09f | ||
|
|
70e33518a9 | ||
|
|
cb81a9f783 | ||
|
|
b6b499d865 | ||
|
|
6704ad8193 | ||
|
|
942628a261 | ||
|
|
4ea8bac10d | ||
|
|
eafccc5721 |
@@ -80,8 +80,8 @@ protobuf {
|
||||
}
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 667
|
||||
def canonicalVersionName = "4.65.2"
|
||||
def canonicalVersionCode = 692
|
||||
def canonicalVersionName = "4.69.2"
|
||||
|
||||
def postFixSize = 10
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -122,7 +122,7 @@ android {
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"cd6cfc342937b23b1bdd3bbf9721aa5615ac9ff50a75c5527d441cd3276826c9\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"bd123560b01c8fa92935bc5ae15cd2064e5c45215f23f0bd40364d521329d2ad\""
|
||||
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\""
|
||||
buildConfigField "String", "KBS_MRENCLAVE", "\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\""
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
@@ -197,7 +197,7 @@ android {
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"ba4ebb438bc07713819ee6c98d94037747006d7df63fc9e44d2d6f1fec962a79\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"bd123560b01c8fa92935bc5ae15cd2064e5c45215f23f0bd40364d521329d2ad\""
|
||||
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\""
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
|
||||
@@ -304,7 +304,7 @@ dependencies {
|
||||
|
||||
implementation 'org.signal:argon2:13.1@aar'
|
||||
|
||||
implementation 'org.signal:ringrtc-android:2.2.0'
|
||||
implementation 'org.signal:ringrtc-android:2.4.1'
|
||||
|
||||
implementation "me.leolin:ShortcutBadger:1.1.16"
|
||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
||||
@@ -358,11 +358,11 @@ dependencies {
|
||||
|
||||
testImplementation 'junit:junit:4.12'
|
||||
testImplementation 'org.assertj:assertj-core:3.11.1'
|
||||
testImplementation 'org.mockito:mockito-core:1.9.5'
|
||||
testImplementation 'org.powermock:powermock-api-mockito:1.6.1'
|
||||
testImplementation 'org.powermock:powermock-module-junit4:1.6.1'
|
||||
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1'
|
||||
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1'
|
||||
testImplementation 'org.mockito:mockito-core:2.8.9'
|
||||
testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
|
||||
testImplementation 'org.powermock:powermock-module-junit4:1.7.4'
|
||||
testImplementation 'org.powermock:powermock-module-junit4-rule:1.7.4'
|
||||
testImplementation 'org.powermock:powermock-classloading-xstream:1.7.4'
|
||||
|
||||
testImplementation 'androidx.test:core:1.2.0'
|
||||
testImplementation ('org.robolectric:robolectric:4.2') {
|
||||
@@ -455,3 +455,13 @@ def getLastCommitTimestamp() {
|
||||
return os.toString() + "000"
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Test) {
|
||||
testLogging {
|
||||
events "failed"
|
||||
exceptionFormat "full"
|
||||
showCauses true
|
||||
showExceptions true
|
||||
showStackTraces true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<lint>
|
||||
|
||||
<!-- Wont pass lint or qa with a STOPSHIP in a comment -->
|
||||
<issue id="StopShip" severity="fatal" />
|
||||
|
||||
<!-- L10N errors -->
|
||||
<!-- This is a runtime crash so we don't want to ship with this. -->
|
||||
<issue id="StringFormatMatches" severity="error" />
|
||||
@@ -8,10 +11,13 @@
|
||||
<!-- L10N warnings -->
|
||||
<issue id="MissingTranslation" severity="ignore" />
|
||||
<issue id="MissingQuantity" severity="warning" />
|
||||
<issue id="MissingDefaultResource" severity="error">
|
||||
<ignore path="*/res/values-*/strings.xml" /> <!-- Ignore for non-English, excludeNonTranslatables task will remove these -->
|
||||
</issue>
|
||||
<issue id="ExtraTranslation" severity="warning" />
|
||||
<issue id="ImpliedQuantity" severity="warning" />
|
||||
<issue id="TypographyDashes" severity="error" >
|
||||
<ignore path="*/res/values-*" /> <!-- Ignore for non-English -->
|
||||
<ignore path="*/res/values-*/strings.xml" /> <!-- Ignore for non-English -->
|
||||
</issue>
|
||||
|
||||
<issue id="CanvasSize" severity="error" />
|
||||
|
||||
@@ -120,8 +120,15 @@
|
||||
android:supportsPictureInPicture="true"
|
||||
android:windowSoftInputMode="stateAlwaysHidden"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
||||
android:taskAffinity=".calling"
|
||||
android:launchMode="singleTask"/>
|
||||
|
||||
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:screenOrientation="portrait"
|
||||
android:noHistory="true"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".InviteActivity"
|
||||
android:theme="@style/Signal.Light.NoActionBar.Invite"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
@@ -216,6 +223,14 @@
|
||||
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https"
|
||||
android:host="group.signal.org"/>
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="com.sec.minimode.icon.portrait.normal"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
<meta-data android:name="com.sec.minimode.icon.landscape.normal"
|
||||
@@ -448,6 +463,9 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity android:name=".contacts.TurnOffContactJoinedNotificationsActivity"
|
||||
android:theme="@style/Theme.AppCompat.Dialog.Alert" />
|
||||
|
||||
<activity android:name=".messagerequests.MessageRequestMegaphoneActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
@@ -497,6 +515,9 @@
|
||||
<activity android:name=".groups.ui.creategroup.details.AddGroupDetailsActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
|
||||
|
||||
<activity android:name=".groups.ui.chooseadmin.ChooseNewAdminActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
|
||||
|
||||
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"/>
|
||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||
|
||||
@@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.messages.InitialMessageRetriever;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
@@ -61,7 +60,6 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
|
||||
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
|
||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
|
||||
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
|
||||
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
@@ -97,7 +95,6 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
private ViewOnceMessageManager viewOnceMessageManager;
|
||||
private TypingStatusRepository typingStatusRepository;
|
||||
private TypingStatusSender typingStatusSender;
|
||||
private IncomingMessageObserver incomingMessageObserver;
|
||||
private PersistentLogger persistentLogger;
|
||||
|
||||
private volatile boolean isAppVisible;
|
||||
@@ -135,8 +132,8 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
NotificationChannels.create(this);
|
||||
RefreshPreKeysJob.scheduleIfNecessary();
|
||||
StorageSyncHelper.scheduleRoutineSync();
|
||||
RetrieveProfileJob.enqueueRoutineFetchIfNeccessary(this);
|
||||
RegistrationUtil.markRegistrationPossiblyComplete();
|
||||
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
|
||||
RegistrationUtil.maybeMarkRegistrationComplete(this);
|
||||
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);
|
||||
|
||||
if (Build.VERSION.SDK_INT < 21) {
|
||||
@@ -157,7 +154,6 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
ApplicationDependencies.getFrameRateTracker().begin();
|
||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||
catchUpOnMessages();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -234,7 +230,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
}
|
||||
|
||||
public void initializeMessageRetrieval() {
|
||||
this.incomingMessageObserver = new IncomingMessageObserver(this);
|
||||
ApplicationDependencies.getIncomingMessageObserver();
|
||||
}
|
||||
|
||||
private void initializeAppDependencies() {
|
||||
@@ -382,36 +378,6 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
|
||||
});
|
||||
}
|
||||
|
||||
private void catchUpOnMessages() {
|
||||
InitialMessageRetriever retriever = ApplicationDependencies.getInitialMessageRetriever();
|
||||
|
||||
if (retriever.isCaughtUp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
switch (retriever.begin(TimeUnit.SECONDS.toMillis(60))) {
|
||||
case SUCCESS:
|
||||
Log.i(TAG, "Successfully caught up on messages. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case FAILURE_TIMEOUT:
|
||||
Log.w(TAG, "Did not finish catching up due to a timeout. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case FAILURE_ERROR:
|
||||
Log.w(TAG, "Did not finish catching up due to an error. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case SKIPPED_ALREADY_CAUGHT_UP:
|
||||
Log.i(TAG, "Already caught up. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
case SKIPPED_ALREADY_RUNNING:
|
||||
Log.i(TAG, "Already in the process of catching up. " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(Context base) {
|
||||
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(base, TextSecurePreferences.getLanguage(base)));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -9,7 +10,10 @@ import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.transition.TransitionInflater;
|
||||
import android.view.DisplayCutout;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.view.Window;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.ImageView;
|
||||
@@ -72,15 +76,19 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
|
||||
}
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
|
||||
ImageView avatar = findViewById(R.id.avatar);
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ImageView avatar = findViewById(R.id.avatar);
|
||||
|
||||
setSupportActionBar(toolbar);
|
||||
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
|
||||
toolbar.getViewTreeObserver().addOnGlobalLayoutListener(new DisplayCutoutAdjuster(toolbar, findViewById(R.id.toolbar_cutout_spacer)));
|
||||
}
|
||||
|
||||
showSystemUI();
|
||||
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
@@ -180,4 +188,36 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust a spacer for the toolbar when a display cutout is detected. Runs within
|
||||
* a layout listener because the activity delays view attachment due to the transitions
|
||||
* and needs to update on device rotation.
|
||||
*/
|
||||
@TargetApi(28)
|
||||
private static class DisplayCutoutAdjuster implements ViewTreeObserver.OnGlobalLayoutListener {
|
||||
|
||||
private final View view;
|
||||
private final View spacer;
|
||||
|
||||
private DisplayCutoutAdjuster(@NonNull View view, @NonNull View spacer) {
|
||||
this.view = view;
|
||||
this.spacer = spacer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGlobalLayout() {
|
||||
if (view.getRootWindowInsets() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DisplayCutout cutout = view.getRootWindowInsets().getDisplayCutout();
|
||||
if (cutout != null) {
|
||||
ViewGroup.LayoutParams params = spacer.getLayoutParams();
|
||||
params.height = cutout.getSafeInsetTop();
|
||||
spacer.setLayoutParams(params);
|
||||
spacer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import android.view.View;
|
||||
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
@@ -21,17 +22,17 @@ import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
public interface BindableConversationItem extends Unbindable {
|
||||
void bind(@NonNull MessageRecord messageRecord,
|
||||
void bind(@NonNull ConversationMessage messageRecord,
|
||||
@NonNull Optional<MessageRecord> previousMessageRecord,
|
||||
@NonNull Optional<MessageRecord> nextMessageRecord,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<MessageRecord> batchSelected,
|
||||
@NonNull Recipient recipients,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseHighlight);
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<ConversationMessage> batchSelected,
|
||||
@NonNull Recipient recipients,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseMention);
|
||||
|
||||
MessageRecord getMessageRecord();
|
||||
ConversationMessage getConversationMessage();
|
||||
|
||||
void setEventListener(@Nullable EventListener listener);
|
||||
|
||||
@@ -45,8 +46,11 @@ public interface BindableConversationItem extends Unbindable {
|
||||
void onAddToContactsClicked(@NonNull Contact contact);
|
||||
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
|
||||
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
|
||||
void onReactionClicked(long messageId, boolean isMms);
|
||||
void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
|
||||
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,9 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactSelected(Optional<RecipientId> recipientId, String number) {}
|
||||
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {}
|
||||
|
||||
@@ -271,11 +271,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
|
||||
|
||||
if (listCallback != null) {
|
||||
if (FeatureFlags.groupsV2create() && FeatureFlags.groupsV2internalTest()) {
|
||||
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback), createNewGroupsV1GroupItem(listCallback));
|
||||
} else {
|
||||
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
|
||||
}
|
||||
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
|
||||
headerAdapter.hide();
|
||||
concatenateAdapter.addAdapter(headerAdapter);
|
||||
}
|
||||
@@ -316,13 +312,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return view;
|
||||
}
|
||||
|
||||
private View createNewGroupsV1GroupItem(@NonNull ListCallback listCallback) {
|
||||
View view = LayoutInflater.from(requireContext())
|
||||
.inflate(R.layout.contact_selection_new_group_v1_item, (ViewGroup) requireView(), false);
|
||||
view.setOnClickListener(v -> listCallback.onNewGroup(true));
|
||||
return view;
|
||||
}
|
||||
|
||||
private void initializeNoContactsPermission() {
|
||||
swipeRefresh.setVisibility(View.GONE);
|
||||
|
||||
@@ -462,6 +451,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber())
|
||||
: SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber());
|
||||
|
||||
if (isMulti() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
|
||||
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMulti() || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
|
||||
if (selectionLimitReached()) {
|
||||
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_the_group_is_full, Toast.LENGTH_SHORT).show();
|
||||
@@ -479,11 +473,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
if (uuid.isPresent()) {
|
||||
Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null);
|
||||
if (onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null)) {
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
} else {
|
||||
markContactSelected(selected);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
} else {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
@@ -494,11 +492,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
});
|
||||
} else {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber());
|
||||
if (onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber())) {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
} else {
|
||||
markContactSelected(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -630,7 +631,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
void onContactSelected(Optional<RecipientId> recipientId, String number);
|
||||
/** @return True if the contact is allowed to be selected, otherwise false. */
|
||||
boolean onContactSelected(Optional<RecipientId> recipientId, String number);
|
||||
void onContactDeselected(Optional<RecipientId> recipientId, String number);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import cn.carbswang.android.numberpickerview.library.NumberPickerView;
|
||||
|
||||
public class ExpirationDialog extends AlertDialog {
|
||||
@@ -36,7 +39,7 @@ public class ExpirationDialog extends AlertDialog {
|
||||
builder.setView(view);
|
||||
builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
int selected = ((NumberPickerView)view.findViewById(R.id.expiration_number_picker)).getValue();
|
||||
listener.onClick(context.getResources().getIntArray(R.array.expiration_times)[selected]);
|
||||
listener.onClick(getExpirationTimes(context, currentExpiration)[selected]);
|
||||
});
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
builder.show();
|
||||
@@ -47,7 +50,7 @@ public class ExpirationDialog extends AlertDialog {
|
||||
final View view = inflater.inflate(R.layout.expiration_dialog, null);
|
||||
final NumberPickerView numberPickerView = view.findViewById(R.id.expiration_number_picker);
|
||||
final TextView textView = view.findViewById(R.id.expiration_details);
|
||||
final int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
|
||||
final int[] expirationTimes = getExpirationTimes(context, currentExpiration);
|
||||
final String[] expirationDisplayValues = new String[expirationTimes.length];
|
||||
|
||||
int selectedIndex = expirationTimes.length - 1;
|
||||
@@ -80,6 +83,19 @@ public class ExpirationDialog extends AlertDialog {
|
||||
return view;
|
||||
}
|
||||
|
||||
private static int[] getExpirationTimes(Context context, int currentExpiration) {
|
||||
int[] expirationTimes = context.getResources().getIntArray(R.array.expiration_times);
|
||||
int location = Arrays.binarySearch(expirationTimes, currentExpiration);
|
||||
if (location < 0) {
|
||||
int[] temp = Arrays.copyOf(expirationTimes, expirationTimes.length + 1);
|
||||
temp[temp.length - 1] = currentExpiration;
|
||||
Arrays.sort(temp);
|
||||
expirationTimes = temp;
|
||||
}
|
||||
|
||||
return expirationTimes;
|
||||
}
|
||||
|
||||
public interface OnClickListener {
|
||||
public void onClick(int expirationTime);
|
||||
}
|
||||
|
||||
@@ -121,8 +121,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
updateSmsButtonText();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
@@ -18,6 +21,14 @@ public class MainActivity extends PassphraseRequiredActivity {
|
||||
setContentView(R.layout.main_activity);
|
||||
|
||||
navigator.onCreate(savedInstanceState);
|
||||
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
handleGroupLinkInIntent(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -42,4 +53,11 @@ public class MainActivity extends PassphraseRequiredActivity {
|
||||
public @NonNull MainNavigator getNavigator() {
|
||||
return navigator;
|
||||
}
|
||||
|
||||
private void handleGroupLinkInIntent(Intent intent) {
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
CommunicationActions.handlePotentialGroupLinkUrl(this, data.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
@@ -73,6 +75,7 @@ import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Activity for displaying media attachments in-app
|
||||
@@ -117,17 +120,20 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
private boolean showThread;
|
||||
private MediaDatabase.Sorting sorting;
|
||||
|
||||
private @Nullable Cursor cursor = null;
|
||||
|
||||
public static @NonNull Intent intentFromMediaRecord(@NonNull Context context,
|
||||
@NonNull MediaRecord mediaRecord,
|
||||
boolean leftIsRecent)
|
||||
{
|
||||
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
||||
Intent intent = new Intent(context, MediaPreviewActivity.class);
|
||||
intent.putExtra(MediaPreviewActivity.THREAD_ID_EXTRA, mediaRecord.getThreadId());
|
||||
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
|
||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, mediaRecord.getAttachment().getSize());
|
||||
intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, mediaRecord.getAttachment().getCaption());
|
||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, attachment.getSize());
|
||||
intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, attachment.getCaption());
|
||||
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, leftIsRecent);
|
||||
intent.setDataAndType(mediaRecord.getAttachment().getDataUri(), mediaRecord.getContentType());
|
||||
intent.setDataAndType(attachment.getDataUri(), mediaRecord.getContentType());
|
||||
return intent;
|
||||
}
|
||||
|
||||
@@ -228,6 +234,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
restartItem = cleanupMedia();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
cursor = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
@@ -344,6 +359,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
|
||||
mediaPager.removeAllViews();
|
||||
mediaPager.setAdapter(null);
|
||||
viewModel.setCursor(this, null, leftIsRecent);
|
||||
|
||||
return restartItem;
|
||||
}
|
||||
@@ -475,19 +491,46 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
|
||||
if (data != null) {
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(),this, data.first, data.second, leftIsRecent);
|
||||
if (data.first == cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
cursor = Objects.requireNonNull(data.first);
|
||||
|
||||
int mediaPosition = Objects.requireNonNull(data.second);
|
||||
|
||||
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(),this, cursor, mediaPosition, leftIsRecent);
|
||||
mediaPager.setAdapter(adapter);
|
||||
adapter.setActive(true);
|
||||
|
||||
viewModel.setCursor(this, data.first, leftIsRecent);
|
||||
viewModel.setCursor(this, cursor, leftIsRecent);
|
||||
|
||||
int item = restartItem >= 0 ? restartItem : data.second;
|
||||
int item = restartItem >= 0 ? restartItem : mediaPosition;
|
||||
mediaPager.setCurrentItem(item);
|
||||
|
||||
if (item == 0) {
|
||||
viewPagerListener.onPageSelected(0);
|
||||
}
|
||||
|
||||
cursor.registerContentObserver(new ContentObserver(new Handler(getMainLooper())) {
|
||||
@Override
|
||||
public void onChange(boolean selfChange) {
|
||||
onMediaChange();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
mediaNotAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
private void onMediaChange() {
|
||||
MediaItemAdapter adapter = (MediaItemAdapter) mediaPager.getAdapter();
|
||||
|
||||
if (adapter != null) {
|
||||
adapter.checkMedia(mediaPager.getCurrentItem());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +545,12 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mediaNotAvailable() {
|
||||
Toast.makeText(this, R.string.MediaPreviewActivity_media_no_longer_available, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
|
||||
private void toggleUiVisibility() {
|
||||
int systemUiVisibility = getWindow().getDecorView().getSystemUiVisibility();
|
||||
if ((systemUiVisibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0) {
|
||||
@@ -621,6 +670,11 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
public boolean hasFragmentFor(int position) {
|
||||
return mediaPreviewFragment != null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkMedia(int currentItem) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private static void anchorMarginsToBottomInsets(@NonNull View viewToAnchor) {
|
||||
@@ -712,7 +766,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
cursor.moveToPosition(cursorPosition);
|
||||
|
||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(context, cursor);
|
||||
DatabaseAttachment attachment = mediaRecord.getAttachment();
|
||||
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
||||
MediaPreviewFragment fragment = MediaPreviewFragment.newInstance(attachment, autoPlay);
|
||||
|
||||
mediaFragments.put(position, fragment);
|
||||
@@ -734,16 +788,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
public MediaItem getMediaItemFor(int position) {
|
||||
cursor.moveToPosition(getCursorPosition(position));
|
||||
|
||||
MediaRecord mediaRecord = MediaRecord.from(context, cursor);
|
||||
RecipientId recipientId = mediaRecord.getRecipientId();
|
||||
RecipientId threadRecipientId = mediaRecord.getThreadRecipientId();
|
||||
|
||||
if (mediaRecord.getAttachment().getDataUri() == null) throw new AssertionError();
|
||||
MediaRecord mediaRecord = MediaRecord.from(context, cursor);
|
||||
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
||||
RecipientId recipientId = mediaRecord.getRecipientId();
|
||||
RecipientId threadRecipientId = mediaRecord.getThreadRecipientId();
|
||||
|
||||
return new MediaItem(Recipient.live(recipientId).get(),
|
||||
Recipient.live(threadRecipientId).get(),
|
||||
mediaRecord.getAttachment(),
|
||||
mediaRecord.getAttachment().getDataUri(),
|
||||
attachment,
|
||||
Objects.requireNonNull(attachment.getDataUri()),
|
||||
mediaRecord.getContentType(),
|
||||
mediaRecord.getDate(),
|
||||
mediaRecord.isOutgoing());
|
||||
@@ -767,6 +820,14 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
return mediaFragments.containsKey(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkMedia(int position) {
|
||||
MediaPreviewFragment fragment = mediaFragments.get(position);
|
||||
if (fragment != null) {
|
||||
fragment.checkMediaStillAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
private int getCursorPosition(int position) {
|
||||
if (leftIsRecent) return position;
|
||||
else return cursor.getCount() - 1 - position;
|
||||
@@ -805,5 +866,6 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
void pause(int position);
|
||||
@Nullable View getPlaybackControls(int position);
|
||||
boolean hasFragmentFor(int position);
|
||||
void checkMedia(int currentItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,15 +21,24 @@ import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Activity container for starting a new conversation.
|
||||
*
|
||||
@@ -51,15 +60,40 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
Recipient recipient;
|
||||
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
|
||||
if (recipientId.isPresent()) {
|
||||
recipient = Recipient.resolved(recipientId.get());
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
|
||||
recipient = Recipient.external(this, number);
|
||||
if (FeatureFlags.cds() && NetworkConstraint.isMet(this)) {
|
||||
Log.i(TAG, "[onContactSelected] CDS enabled. Doing contact refresh.");
|
||||
|
||||
AlertDialog progress = SimpleProgressDialog.show(this);
|
||||
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
Recipient resolved = Recipient.external(this, number);
|
||||
|
||||
if (!resolved.isRegistered()) {
|
||||
Log.i(TAG, "[onContactSelected] Not registered. Doing a directory refresh.");
|
||||
try {
|
||||
DirectoryHelper.refreshDirectoryFor(this, resolved, false);
|
||||
resolved = Recipient.resolved(resolved.getId());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}, resolved -> {
|
||||
progress.dismiss();
|
||||
launch(resolved);
|
||||
});
|
||||
} else {
|
||||
launch(Recipient.external(this, number));
|
||||
}
|
||||
}
|
||||
launch(recipient);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void launch(Recipient recipient) {
|
||||
|
||||
@@ -164,7 +164,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private boolean userMustCreateSignalPin() {
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed();
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut();
|
||||
}
|
||||
|
||||
private boolean userMustSetProfileName() {
|
||||
|
||||
@@ -307,7 +307,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
|
||||
byte[] localId;
|
||||
byte[] remoteId;
|
||||
|
||||
if (FeatureFlags.uuids() && recipient.resolve().getUuid().isPresent()) {
|
||||
if (FeatureFlags.verifyV2() && recipient.resolve().getUuid().isPresent()) {
|
||||
Log.i(TAG, "Using UUID (version 2).");
|
||||
version = 2;
|
||||
localId = UuidUtil.toByteArray(TextSecurePreferences.getLocalUuid(requireContext()));
|
||||
|
||||
@@ -51,6 +51,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
|
||||
import org.thoughtcrime.securesms.events.WebRtcViewModel;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
|
||||
@@ -155,14 +156,13 @@ public class WebRtcCallActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onUserLeaveHint() {
|
||||
if (deviceSupportsPipMode()) {
|
||||
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(new Rational(16, 9))
|
||||
.build();
|
||||
setPictureInPictureParams(params);
|
||||
enterPipModeIfPossible();
|
||||
}
|
||||
|
||||
//noinspection deprecation
|
||||
enterPictureInPictureMode();
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (!enterPipModeIfPossible()) {
|
||||
super.onBackPressed();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,8 +171,19 @@ public class WebRtcCallActivity extends AppCompatActivity {
|
||||
viewModel.setIsInPipMode(isInPictureInPictureMode);
|
||||
}
|
||||
|
||||
private boolean enterPipModeIfPossible() {
|
||||
if (isSystemPipEnabledAndAvailable()) {
|
||||
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
||||
.setAspectRatio(new Rational(9, 16))
|
||||
.build();
|
||||
enterPictureInPictureMode(params);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isInPipMode() {
|
||||
return deviceSupportsPipMode() && isInPictureInPictureMode();
|
||||
return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode();
|
||||
}
|
||||
|
||||
private void processIntent(@NonNull Intent intent) {
|
||||
@@ -391,6 +402,9 @@ public class WebRtcCallActivity extends AppCompatActivity {
|
||||
|
||||
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
||||
|
||||
if (hangupType == HangupMessage.Type.NEED_PERMISSION) {
|
||||
startActivity(CalleeMustAcceptMessageRequestActivity.createIntent(this, recipient.getId()));
|
||||
}
|
||||
delayedFinish();
|
||||
}
|
||||
|
||||
@@ -489,9 +503,8 @@ public class WebRtcCallActivity extends AppCompatActivity {
|
||||
.show();
|
||||
}
|
||||
|
||||
private boolean deviceSupportsPipMode() {
|
||||
private boolean isSystemPipEnabledAndAvailable() {
|
||||
return Build.VERSION.SDK_INT >= 26 &&
|
||||
FeatureFlags.callingPip() &&
|
||||
getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
|
||||
}
|
||||
|
||||
@@ -510,19 +523,20 @@ public class WebRtcCallActivity extends AppCompatActivity {
|
||||
viewModel.setRecipient(event.getRecipient());
|
||||
|
||||
switch (event.getState()) {
|
||||
case CALL_CONNECTED: handleCallConnected(event); break;
|
||||
case NETWORK_FAILURE: handleServerFailure(event); break;
|
||||
case CALL_RINGING: handleCallRinging(event); break;
|
||||
case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
|
||||
case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
|
||||
case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
|
||||
case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
|
||||
case NO_SUCH_USER: handleNoSuchUser(event); break;
|
||||
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(event); break;
|
||||
case CALL_INCOMING: handleIncomingCall(event); break;
|
||||
case CALL_OUTGOING: handleOutgoingCall(event); break;
|
||||
case CALL_BUSY: handleCallBusy(event); break;
|
||||
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
|
||||
case CALL_CONNECTED: handleCallConnected(event); break;
|
||||
case NETWORK_FAILURE: handleServerFailure(event); break;
|
||||
case CALL_RINGING: handleCallRinging(event); break;
|
||||
case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
|
||||
case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
|
||||
case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
|
||||
case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
|
||||
case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
|
||||
case NO_SUCH_USER: handleNoSuchUser(event); break;
|
||||
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(event); break;
|
||||
case CALL_INCOMING: handleIncomingCall(event); break;
|
||||
case CALL_OUTGOING: handleOutgoingCall(event); break;
|
||||
case CALL_BUSY: handleCallBusy(event); break;
|
||||
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
|
||||
}
|
||||
|
||||
callScreen.setLocalRenderer(event.getLocalRenderer());
|
||||
|
||||
@@ -39,6 +39,7 @@ public abstract class Attachment {
|
||||
private final String fastPreflightId;
|
||||
|
||||
private final boolean voiceNote;
|
||||
private final boolean borderless;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final boolean quote;
|
||||
@@ -59,11 +60,26 @@ public abstract class Attachment {
|
||||
@NonNull
|
||||
private final TransformProperties transformProperties;
|
||||
|
||||
public Attachment(@NonNull String contentType, int transferState, long size, @Nullable String fileName,
|
||||
int cdnNumber, @Nullable String location, @Nullable String key, @Nullable String relay,
|
||||
@Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote,
|
||||
int width, int height, boolean quote, long uploadTimestamp, @Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash,
|
||||
public Attachment(@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
@Nullable String fileName,
|
||||
int cdnNumber,
|
||||
@Nullable String location,
|
||||
@Nullable String key,
|
||||
@Nullable String relay,
|
||||
@Nullable byte[] digest,
|
||||
@Nullable String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
int width,
|
||||
int height,
|
||||
boolean quote,
|
||||
long uploadTimestamp,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
@@ -77,6 +93,7 @@ public abstract class Attachment {
|
||||
this.digest = digest;
|
||||
this.fastPreflightId = fastPreflightId;
|
||||
this.voiceNote = voiceNote;
|
||||
this.borderless = borderless;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.quote = quote;
|
||||
@@ -150,6 +167,10 @@ public abstract class Attachment {
|
||||
return voiceNote;
|
||||
}
|
||||
|
||||
public boolean isBorderless() {
|
||||
return borderless;
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
@@ -20,17 +20,34 @@ public class DatabaseAttachment extends Attachment {
|
||||
private final boolean hasThumbnail;
|
||||
private final int displayOrder;
|
||||
|
||||
public DatabaseAttachment(AttachmentId attachmentId, long mmsId,
|
||||
boolean hasData, boolean hasThumbnail,
|
||||
String contentType, int transferProgress, long size,
|
||||
String fileName, int cdnNumber, String location, String key, String relay,
|
||||
byte[] digest, String fastPreflightId, boolean voiceNote,
|
||||
int width, int height, boolean quote, @Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties, int displayOrder,
|
||||
public DatabaseAttachment(AttachmentId attachmentId,
|
||||
long mmsId,
|
||||
boolean hasData,
|
||||
boolean hasThumbnail,
|
||||
String contentType,
|
||||
int transferProgress,
|
||||
long size,
|
||||
String fileName,
|
||||
int cdnNumber,
|
||||
String location,
|
||||
String key,
|
||||
String relay,
|
||||
byte[] digest,
|
||||
String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
int width,
|
||||
int height,
|
||||
boolean quote,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties,
|
||||
int displayOrder,
|
||||
long uploadTimestamp)
|
||||
{
|
||||
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, borderless, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.attachmentId = attachmentId;
|
||||
this.hasData = hasData;
|
||||
this.hasThumbnail = hasThumbnail;
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
public class MmsNotificationAttachment extends Attachment {
|
||||
|
||||
public MmsNotificationAttachment(int status, long size) {
|
||||
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, 0, 0, false, 0, null, null, null, null, null);
|
||||
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, false, 0, 0, false, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
||||
@@ -18,14 +18,26 @@ import java.util.List;
|
||||
|
||||
public class PointerAttachment extends Attachment {
|
||||
|
||||
private PointerAttachment(@NonNull String contentType, int transferState, long size,
|
||||
@Nullable String fileName, int cdnNumber, @NonNull String location,
|
||||
@Nullable String key, @Nullable String relay,
|
||||
@Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote,
|
||||
int width, int height, long uploadTimestamp, @Nullable String caption, @Nullable StickerLocator stickerLocator,
|
||||
private PointerAttachment(@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
@Nullable String fileName,
|
||||
int cdnNumber,
|
||||
@NonNull String location,
|
||||
@Nullable String key,
|
||||
@Nullable String relay,
|
||||
@Nullable byte[] digest,
|
||||
@Nullable String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
int width,
|
||||
int height,
|
||||
long uploadTimestamp,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
|
||||
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, borderless, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -91,21 +103,22 @@ public class PointerAttachment extends Attachment {
|
||||
}
|
||||
|
||||
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
|
||||
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
||||
pointer.get().asPointer().getSize().or(0),
|
||||
pointer.get().asPointer().getFileName().orNull(),
|
||||
pointer.get().asPointer().getCdnNumber(),
|
||||
pointer.get().asPointer().getRemoteId().toString(),
|
||||
encodedKey, null,
|
||||
pointer.get().asPointer().getDigest().orNull(),
|
||||
fastPreflightId,
|
||||
pointer.get().asPointer().getVoiceNote(),
|
||||
pointer.get().asPointer().getWidth(),
|
||||
pointer.get().asPointer().getHeight(),
|
||||
pointer.get().asPointer().getUploadTimestamp(),
|
||||
pointer.get().asPointer().getCaption().orNull(),
|
||||
stickerLocator,
|
||||
BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orNull())));
|
||||
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
||||
pointer.get().asPointer().getSize().or(0),
|
||||
pointer.get().asPointer().getFileName().orNull(),
|
||||
pointer.get().asPointer().getCdnNumber(),
|
||||
pointer.get().asPointer().getRemoteId().toString(),
|
||||
encodedKey, null,
|
||||
pointer.get().asPointer().getDigest().orNull(),
|
||||
fastPreflightId,
|
||||
pointer.get().asPointer().getVoiceNote(),
|
||||
pointer.get().asPointer().isBorderless(),
|
||||
pointer.get().asPointer().getWidth(),
|
||||
pointer.get().asPointer().getHeight(),
|
||||
pointer.get().asPointer().getUploadTimestamp(),
|
||||
pointer.get().asPointer().getCaption().orNull(),
|
||||
stickerLocator,
|
||||
BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orNull())));
|
||||
|
||||
}
|
||||
|
||||
@@ -123,6 +136,7 @@ public class PointerAttachment extends Attachment {
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orNull() : null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
public class TombstoneAttachment extends Attachment {
|
||||
|
||||
public TombstoneAttachment(@NonNull String contentType, boolean quote) {
|
||||
super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, 0, 0, quote, 0, null, null, null, null, null);
|
||||
super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, false, 0, 0, quote, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -15,20 +15,42 @@ public class UriAttachment extends Attachment {
|
||||
private final @NonNull Uri dataUri;
|
||||
private final @Nullable Uri thumbnailUri;
|
||||
|
||||
public UriAttachment(@NonNull Uri uri, @NonNull String contentType, int transferState, long size,
|
||||
@Nullable String fileName, boolean voiceNote, boolean quote, @Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash, @Nullable TransformProperties transformProperties)
|
||||
public UriAttachment(@NonNull Uri uri,
|
||||
@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
@Nullable String fileName,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean quote,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, borderless, quote, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
}
|
||||
|
||||
public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri,
|
||||
@NonNull String contentType, int transferState, long size, int width, int height,
|
||||
@Nullable String fileName, @Nullable String fastPreflightId,
|
||||
boolean voiceNote, boolean quote, @Nullable String caption, @Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash, @Nullable AudioHash audioHash, @Nullable TransformProperties transformProperties)
|
||||
public UriAttachment(@NonNull Uri dataUri,
|
||||
@Nullable Uri thumbnailUri,
|
||||
@NonNull String contentType,
|
||||
int transferState,
|
||||
long size,
|
||||
int width,
|
||||
int height,
|
||||
@Nullable String fileName,
|
||||
@Nullable String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
boolean quote,
|
||||
@Nullable String caption,
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash,
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, borderless, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.dataUri = dataUri;
|
||||
this.thumbnailUri = thumbnailUri;
|
||||
}
|
||||
|
||||
@@ -7,23 +7,26 @@ import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterInside;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
|
||||
public class StickerView extends FrameLayout {
|
||||
public class BorderlessImageView extends FrameLayout {
|
||||
|
||||
private ThumbnailView image;
|
||||
private View missingShade;
|
||||
|
||||
public StickerView(@NonNull Context context) {
|
||||
public BorderlessImageView(@NonNull Context context) {
|
||||
super(context);
|
||||
init();
|
||||
}
|
||||
|
||||
public StickerView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
public BorderlessImageView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
init();
|
||||
}
|
||||
@@ -50,10 +53,17 @@ public class StickerView extends FrameLayout {
|
||||
image.setOnLongClickListener(l);
|
||||
}
|
||||
|
||||
public void setSticker(@NonNull GlideRequests glideRequests, @NonNull Slide stickerSlide) {
|
||||
boolean showControls = stickerSlide.asAttachment().getDataUri() == null;
|
||||
public void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
|
||||
boolean showControls = slide.asAttachment().getDataUri() == null;
|
||||
|
||||
if (slide.hasSticker()) {
|
||||
image.setFit(new CenterInside());
|
||||
image.setImageResource(glideRequests, slide, showControls, false);
|
||||
} else {
|
||||
image.setFit(new CenterCrop());
|
||||
image.setImageResource(glideRequests, slide, showControls, false, slide.asAttachment().getWidth(), slide.asAttachment().getHeight());
|
||||
}
|
||||
|
||||
image.setImageResource(glideRequests, stickerSlide, showControls, false);
|
||||
missingShade.setVisibility(showControls ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
@@ -2,40 +2,57 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Canvas;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
||||
import androidx.core.os.BuildCompat;
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextUtils.TruncateAt;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.util.AttributeSet;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.view.inputmethod.InputConnection;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.inputmethod.EditorInfoCompat;
|
||||
import androidx.core.view.inputmethod.InputConnectionCompat;
|
||||
import androidx.core.view.inputmethod.InputContentInfoCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.TransportOption;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionDeleter;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
||||
|
||||
public class ComposeText extends EmojiEditText {
|
||||
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private CharSequence combinedHint;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
private MentionValidatorWatcher mentionValidatorWatcher;
|
||||
|
||||
@Nullable private InputPanel.MediaListener mediaListener;
|
||||
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
|
||||
@Nullable private MentionQueryChangedListener mentionQueryChangedListener;
|
||||
|
||||
public ComposeText(Context context) {
|
||||
super(context);
|
||||
@@ -52,34 +69,72 @@ public class ComposeText extends EmojiEditText {
|
||||
initialize();
|
||||
}
|
||||
|
||||
public String getTextTrimmed(){
|
||||
return getText().toString().trim();
|
||||
/**
|
||||
* Trims and returns text while preserving potential spans like {@link MentionAnnotation}.
|
||||
*/
|
||||
public @NonNull CharSequence getTextTrimmed() {
|
||||
Editable text = getText();
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
return StringUtil.trimSequence(text);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||
super.onLayout(changed, left, top, right, bottom);
|
||||
|
||||
if (!TextUtils.isEmpty(hint)) {
|
||||
if (!TextUtils.isEmpty(subHint)) {
|
||||
setHint(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(subHint)));
|
||||
} else {
|
||||
setHint(ellipsizeToWidth(hint));
|
||||
}
|
||||
if (!TextUtils.isEmpty(combinedHint)) {
|
||||
setHint(combinedHint);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSelectionChanged(int selStart, int selEnd) {
|
||||
super.onSelectionChanged(selStart, selEnd);
|
||||
protected void onSelectionChanged(int selectionStart, int selectionEnd) {
|
||||
super.onSelectionChanged(selectionStart, selectionEnd);
|
||||
|
||||
if (FeatureFlags.mentions() && getText() != null) {
|
||||
boolean selectionChanged = changeSelectionForPartialMentions(getText(), selectionStart, selectionEnd);
|
||||
if (selectionChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectionStart == selectionEnd) {
|
||||
doAfterCursorChange(getText());
|
||||
} else {
|
||||
updateQuery(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorPositionChangedListener != null) {
|
||||
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
|
||||
cursorPositionChangedListener.onCursorPositionChanged(selectionStart, selectionEnd);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (getText() != null && getLayout() != null) {
|
||||
int checkpoint = canvas.save();
|
||||
|
||||
// Clip using same logic as TextView drawing
|
||||
int maxScrollY = getLayout().getHeight() - getBottom() - getTop() - getCompoundPaddingBottom() - getCompoundPaddingTop();
|
||||
float clipLeft = getCompoundPaddingLeft() + getScrollX();
|
||||
float clipTop = (getScrollY() == 0) ? 0 : getExtendedPaddingTop() + getScrollY();
|
||||
float clipRight = getRight() - getLeft() - getCompoundPaddingRight() + getScrollX();
|
||||
float clipBottom = getBottom() - getTop() + getScrollY() - ((getScrollY() == maxScrollY) ? 0 : getExtendedPaddingBottom());
|
||||
|
||||
canvas.clipRect(clipLeft - 10, clipTop, clipRight + 10, clipBottom);
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
|
||||
try {
|
||||
mentionRendererDelegate.draw(canvas, getText(), getLayout());
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas);
|
||||
}
|
||||
|
||||
private CharSequence ellipsizeToWidth(CharSequence text) {
|
||||
return TextUtils.ellipsize(text,
|
||||
getPaint(),
|
||||
@@ -88,25 +143,25 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
|
||||
this.hint = hint;
|
||||
|
||||
if (subHint != null) {
|
||||
this.subHint = new SpannableString(subHint);
|
||||
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
|
||||
Spannable subHintSpannable = new SpannableString(subHint);
|
||||
subHintSpannable.setSpan(new RelativeSizeSpan(0.5f), 0, subHintSpannable.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
|
||||
|
||||
combinedHint = new SpannableStringBuilder().append(ellipsizeToWidth(hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(subHintSpannable));
|
||||
} else {
|
||||
this.subHint = null;
|
||||
combinedHint = ellipsizeToWidth(hint);
|
||||
}
|
||||
|
||||
if (this.subHint != null) {
|
||||
super.setHint(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
|
||||
.append("\n")
|
||||
.append(ellipsizeToWidth(this.subHint)));
|
||||
} else {
|
||||
super.setHint(ellipsizeToWidth(this.hint));
|
||||
}
|
||||
super.setHint(combinedHint);
|
||||
}
|
||||
|
||||
public void appendInvite(String invite) {
|
||||
if (getText() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(getText()) && !getText().toString().equals(" ")) {
|
||||
append(" ");
|
||||
}
|
||||
@@ -119,13 +174,22 @@ public class ComposeText extends EmojiEditText {
|
||||
this.cursorPositionChangedListener = listener;
|
||||
}
|
||||
|
||||
public void setMentionQueryChangedListener(@Nullable MentionQueryChangedListener listener) {
|
||||
this.mentionQueryChangedListener = listener;
|
||||
}
|
||||
|
||||
public void setMentionValidator(@Nullable MentionValidatorWatcher.MentionValidator mentionValidator) {
|
||||
if (FeatureFlags.mentions()) {
|
||||
mentionValidatorWatcher.setMentionValidator(mentionValidator);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLandscape() {
|
||||
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
|
||||
}
|
||||
|
||||
public void setTransport(TransportOption transport) {
|
||||
final boolean useSystemEmoji = TextSecurePreferences.isSystemEmojiPreferred(getContext());
|
||||
final boolean isIncognito = TextSecurePreferences.isIncognitoKeyboardEnabled(getContext());
|
||||
|
||||
int imeOptions = (getImeOptions() & ~EditorInfo.IME_MASK_ACTION) | EditorInfo.IME_ACTION_SEND;
|
||||
int inputType = getInputType();
|
||||
@@ -137,12 +201,12 @@ public class ComposeText extends EmojiEditText {
|
||||
inputType = (inputType & ~InputType.TYPE_MASK_VARIATION) | InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE;
|
||||
}
|
||||
|
||||
setInputType(inputType);
|
||||
setImeOptions(imeOptions);
|
||||
setHint(transport.getComposeHint(),
|
||||
transport.getSimName().isPresent()
|
||||
? getContext().getString(R.string.conversation_activity__from_sim_name, transport.getSimName().get())
|
||||
: null);
|
||||
setInputType(inputType);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -165,13 +229,131 @@ public class ComposeText extends EmojiEditText {
|
||||
this.mediaListener = mediaListener;
|
||||
}
|
||||
|
||||
public boolean hasMentions() {
|
||||
Editable text = getText();
|
||||
if (text != null) {
|
||||
return !MentionAnnotation.getMentionAnnotations(text).isEmpty();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentions() {
|
||||
return MentionAnnotation.getMentionsFromAnnotations(getText());
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
|
||||
setImeOptions(getImeOptions() | 16777216);
|
||||
}
|
||||
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ThemeUtil.getThemedColor(getContext(), R.attr.conversation_mention_background_color));
|
||||
|
||||
if (FeatureFlags.mentions()) {
|
||||
addTextChangedListener(new MentionDeleter());
|
||||
mentionValidatorWatcher = new MentionValidatorWatcher();
|
||||
addTextChangedListener(mentionValidatorWatcher);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean changeSelectionForPartialMentions(@NonNull Spanned spanned, int selectionStart, int selectionEnd) {
|
||||
Annotation[] annotations = spanned.getSpans(0, spanned.length(), Annotation.class);
|
||||
for (Annotation annotation : annotations) {
|
||||
if (MentionAnnotation.isMentionAnnotation(annotation)) {
|
||||
int spanStart = spanned.getSpanStart(annotation);
|
||||
int spanEnd = spanned.getSpanEnd(annotation);
|
||||
|
||||
boolean startInMention = selectionStart > spanStart && selectionStart < spanEnd;
|
||||
boolean endInMention = selectionEnd > spanStart && selectionEnd < spanEnd;
|
||||
|
||||
if (startInMention || endInMention) {
|
||||
if (selectionStart == selectionEnd) {
|
||||
setSelection(spanEnd, spanEnd);
|
||||
} else {
|
||||
int newStart = startInMention ? spanStart : selectionStart;
|
||||
int newEnd = endInMention ? spanEnd : selectionEnd;
|
||||
setSelection(newStart, newEnd);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void doAfterCursorChange(@NonNull Editable text) {
|
||||
if (enoughToFilter(text)) {
|
||||
performFiltering(text);
|
||||
} else {
|
||||
updateQuery(null);
|
||||
}
|
||||
}
|
||||
|
||||
private void performFiltering(@NonNull Editable text) {
|
||||
int end = getSelectionEnd();
|
||||
int start = findQueryStart(text, end);
|
||||
CharSequence query = text.subSequence(start, end);
|
||||
updateQuery(query.toString());
|
||||
}
|
||||
|
||||
private void updateQuery(@Nullable String query) {
|
||||
if (mentionQueryChangedListener != null) {
|
||||
mentionQueryChangedListener.onQueryChanged(query);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean enoughToFilter(@NonNull Editable text) {
|
||||
int end = getSelectionEnd();
|
||||
if (end < 0) {
|
||||
return false;
|
||||
}
|
||||
return findQueryStart(text, end) != -1;
|
||||
}
|
||||
|
||||
public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) {
|
||||
Editable text = getText();
|
||||
if (text == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearComposingText();
|
||||
|
||||
int end = getSelectionEnd();
|
||||
int start = findQueryStart(text, end) - 1;
|
||||
|
||||
text.replace(start, end, createReplacementToken(displayName, recipientId));
|
||||
}
|
||||
|
||||
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull RecipientId recipientId) {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder().append(MENTION_STARTER);
|
||||
if (text instanceof Spanned) {
|
||||
SpannableString spannableString = new SpannableString(text + " ");
|
||||
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
|
||||
builder.append(spannableString);
|
||||
} else {
|
||||
builder.append(text).append(" ");
|
||||
}
|
||||
|
||||
builder.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(recipientId), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition) {
|
||||
if (inputCursorPosition == 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int delimiterSearchIndex = inputCursorPosition - 1;
|
||||
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != MENTION_STARTER && text.charAt(delimiterSearchIndex) != ' ')) {
|
||||
delimiterSearchIndex--;
|
||||
}
|
||||
|
||||
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == MENTION_STARTER) {
|
||||
return delimiterSearchIndex + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR2)
|
||||
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
|
||||
|
||||
private static final String TAG = CommitContentListener.class.getSimpleName();
|
||||
@@ -184,7 +366,7 @@ public class ComposeText extends EmojiEditText {
|
||||
|
||||
@Override
|
||||
public boolean onCommitContent(InputContentInfoCompat inputContentInfo, int flags, Bundle opts) {
|
||||
if (BuildCompat.isAtLeastNMR1() && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
if (Build.VERSION.SDK_INT >= 25 && (flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) {
|
||||
try {
|
||||
inputContentInfo.requestPermission();
|
||||
} catch (Exception e) {
|
||||
@@ -207,4 +389,8 @@ public class ComposeText extends EmojiEditText {
|
||||
public interface CursorPositionChangedListener {
|
||||
void onCursorPositionChanged(int start, int end);
|
||||
}
|
||||
|
||||
public interface MentionQueryChangedListener {
|
||||
void onQueryChanged(@Nullable String query);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public class ConversationItemThumbnail extends FrameLayout {
|
||||
private ConversationItemFooter footer;
|
||||
private CornerMask cornerMask;
|
||||
private Outliner outliner;
|
||||
private Outliner pulseOutliner;
|
||||
private boolean borderless;
|
||||
|
||||
public ConversationItemThumbnail(Context context) {
|
||||
@@ -80,6 +81,14 @@ public class ConversationItemThumbnail extends FrameLayout {
|
||||
outliner.draw(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
if (pulseOutliner != null) {
|
||||
pulseOutliner.draw(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
public void setPulseOutliner(@NonNull Outliner outliner) {
|
||||
this.pulseOutliner = outliner;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public final class ConversationScrollToView extends FrameLayout {
|
||||
|
||||
private final TextView unreadCount;
|
||||
private final ImageView scrollButton;
|
||||
|
||||
public ConversationScrollToView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public ConversationScrollToView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public ConversationScrollToView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
inflate(context, R.layout.conversation_scroll_to, this);
|
||||
|
||||
unreadCount = findViewById(R.id.conversation_scroll_to_count);
|
||||
scrollButton = findViewById(R.id.conversation_scroll_to_button);
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.ConversationScrollToView);
|
||||
Drawable src = array.getDrawable(R.styleable.ConversationScrollToView_cstv_scroll_button_src);
|
||||
|
||||
scrollButton.setImageDrawable(src);
|
||||
|
||||
array.recycle();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnClickListener(@Nullable OnClickListener l) {
|
||||
scrollButton.setOnClickListener(l);
|
||||
}
|
||||
|
||||
public void setUnreadCount(int unreadCount) {
|
||||
this.unreadCount.setText(formatUnreadCount(unreadCount));
|
||||
this.unreadCount.setVisibility(unreadCount > 0 ? VISIBLE : GONE);
|
||||
}
|
||||
|
||||
private @NonNull CharSequence formatUnreadCount(int unreadCount) {
|
||||
return unreadCount > 999 ? "999+" : String.valueOf(unreadCount);
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,8 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.KeyEvent;
|
||||
@@ -94,7 +92,6 @@ public class InputPanel extends LinearLayout
|
||||
super(context, attrs);
|
||||
}
|
||||
|
||||
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
|
||||
public InputPanel(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
@@ -160,7 +157,7 @@ public class InputPanel extends LinearLayout
|
||||
public void setQuote(@NonNull GlideRequests glideRequests,
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@NonNull String body,
|
||||
@NonNull CharSequence body,
|
||||
@NonNull SlideDeck attachments)
|
||||
{
|
||||
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments);
|
||||
@@ -228,7 +225,7 @@ public class InputPanel extends LinearLayout
|
||||
|
||||
public Optional<QuoteModel> getQuote() {
|
||||
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody(), false, quoteView.getAttachments()));
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
|
||||
} else {
|
||||
return Optional.absent();
|
||||
}
|
||||
@@ -239,6 +236,11 @@ public class InputPanel extends LinearLayout
|
||||
this.linkPreview.setLoading();
|
||||
}
|
||||
|
||||
public void setLinkPreviewNoPreview() {
|
||||
this.linkPreview.setVisibility(View.VISIBLE);
|
||||
this.linkPreview.setNoPreview();
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull Optional<LinkPreview> preview) {
|
||||
if (preview.isPresent()) {
|
||||
this.linkPreview.setVisibility(View.VISIBLE);
|
||||
|
||||
@@ -21,6 +21,9 @@ import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
import okhttp3.HttpUrl;
|
||||
|
||||
/**
|
||||
* The view shown in the compose box that represents the state of the link preview.
|
||||
*/
|
||||
public class LinkPreviewView extends FrameLayout {
|
||||
|
||||
private static final int TYPE_CONVERSATION = 0;
|
||||
@@ -33,6 +36,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
private View divider;
|
||||
private View closeButton;
|
||||
private View spinner;
|
||||
private View noPreview;
|
||||
|
||||
private int type;
|
||||
private int defaultRadius;
|
||||
@@ -60,6 +64,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
divider = findViewById(R.id.linkpreview_divider);
|
||||
spinner = findViewById(R.id.linkpreview_progress_wheel);
|
||||
closeButton = findViewById(R.id.linkpreview_close);
|
||||
noPreview = findViewById(R.id.linkpreview_no_preview);
|
||||
defaultRadius = getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius);
|
||||
cornerMask = new CornerMask(this);
|
||||
outliner = new Outliner();
|
||||
@@ -102,6 +107,15 @@ public class LinkPreviewView extends FrameLayout {
|
||||
site.setVisibility(GONE);
|
||||
thumbnail.setVisibility(GONE);
|
||||
spinner.setVisibility(VISIBLE);
|
||||
noPreview.setVisibility(INVISIBLE);
|
||||
}
|
||||
|
||||
public void setNoPreview() {
|
||||
title.setVisibility(GONE);
|
||||
site.setVisibility(GONE);
|
||||
thumbnail.setVisibility(GONE);
|
||||
spinner.setVisibility(GONE);
|
||||
noPreview.setVisibility(VISIBLE);
|
||||
}
|
||||
|
||||
public void setLinkPreview(@NonNull GlideRequests glideRequests, @NonNull LinkPreview linkPreview, boolean showThumbnail) {
|
||||
@@ -109,6 +123,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
site.setVisibility(VISIBLE);
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
spinner.setVisibility(GONE);
|
||||
noPreview.setVisibility(GONE);
|
||||
|
||||
title.setText(linkPreview.getTitle());
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ public class MaskView extends View {
|
||||
target.getDrawingRect(drawingRect);
|
||||
activityContentView.offsetDescendantRectToMyCoords(target, drawingRect);
|
||||
|
||||
drawingRect.bottom = Math.min(drawingRect.bottom, getBottom() - getPaddingBottom());
|
||||
drawingRect.top += targetParentTranslationY;
|
||||
drawingRect.bottom += targetParentTranslationY;
|
||||
|
||||
@@ -88,6 +87,7 @@ public class MaskView extends View {
|
||||
|
||||
target.draw(maskCanvas);
|
||||
|
||||
canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom()));
|
||||
canvas.drawBitmap(mask, 0, drawingRect.top, maskPaint);
|
||||
|
||||
mask.recycle();
|
||||
|
||||
@@ -25,6 +25,14 @@ public class Outliner {
|
||||
outlinePaint.setColor(color);
|
||||
}
|
||||
|
||||
public void setStrokeWidth(float pixels) {
|
||||
outlinePaint.setStrokeWidth(pixels);
|
||||
}
|
||||
|
||||
public void setAlpha(int alpha) {
|
||||
outlinePaint.setAlpha(alpha);
|
||||
}
|
||||
|
||||
public void draw(Canvas canvas) {
|
||||
draw(canvas, 0, canvas.getWidth(), canvas.getHeight(), 0);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
@@ -55,7 +57,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
|
||||
private long id;
|
||||
private LiveRecipient author;
|
||||
private String body;
|
||||
private CharSequence body;
|
||||
private TextView mediaDescriptionText;
|
||||
private TextView missingLinkText;
|
||||
private SlideDeck attachments;
|
||||
@@ -147,7 +149,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
public void setQuote(GlideRequests glideRequests,
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@Nullable String body,
|
||||
@Nullable CharSequence body,
|
||||
boolean originalMissing,
|
||||
@NonNull SlideDeck attachments)
|
||||
{
|
||||
@@ -196,7 +198,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
mainView.setBackgroundColor(author.getColor().toQuoteBackgroundColor(getContext(), outgoing));
|
||||
}
|
||||
|
||||
private void setQuoteText(@Nullable String body, @NonNull SlideDeck attachments) {
|
||||
private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
|
||||
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
|
||||
bodyView.setVisibility(VISIBLE);
|
||||
bodyView.setText(body == null ? "" : body);
|
||||
@@ -280,11 +282,15 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
return author.get();
|
||||
}
|
||||
|
||||
public String getBody() {
|
||||
public CharSequence getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public List<Attachment> getAttachments() {
|
||||
return attachments.asAttachments();
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentions() {
|
||||
return MentionAnnotation.getMentionsFromAnnotations(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,6 +398,10 @@ public class ThumbnailView extends FrameLayout {
|
||||
getTransferControls().showProgressSpinner();
|
||||
}
|
||||
|
||||
public void setFit(@NonNull BitmapTransformation fit) {
|
||||
this.fit = fit;
|
||||
}
|
||||
|
||||
protected void setRadius(int radius) {
|
||||
this.radius = radius;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,10 @@ public final class WaveFormSeekBarView extends AppCompatSeekBar {
|
||||
canvas.save();
|
||||
canvas.translate(getPaddingLeft(), getPaddingTop());
|
||||
|
||||
if (getLayoutDirection() == LAYOUT_DIRECTION_RTL) {
|
||||
canvas.scale(-1, 1, usableWidth / 2f, usableHeight / 2f);
|
||||
}
|
||||
|
||||
for (int bar = 0; bar < data.length; bar++) {
|
||||
float x = bar * (barWidth + barGap) + barWidth / 2f;
|
||||
float y = data[bar] * maxHeight;
|
||||
|
||||
@@ -37,6 +37,12 @@ public class ZoomingImageView extends FrameLayout {
|
||||
|
||||
private static final String TAG = ZoomingImageView.class.getSimpleName();
|
||||
|
||||
private static final int ZOOM_TRANSITION_DURATION = 300;
|
||||
|
||||
private static final float ZOOM_LEVEL_MIN = 1.0f;
|
||||
private static final float ZOOM_LEVEL_MID = 1.5f;
|
||||
private static final float ZOOM_LEVEL_MAX = 2.0f;
|
||||
|
||||
private final PhotoView photoView;
|
||||
private final SubsamplingScaleImageView subsamplingImageView;
|
||||
|
||||
@@ -58,6 +64,12 @@ public class ZoomingImageView extends FrameLayout {
|
||||
|
||||
this.subsamplingImageView.setOrientation(SubsamplingScaleImageView.ORIENTATION_USE_EXIF);
|
||||
|
||||
this.photoView.setZoomTransitionDuration(ZOOM_TRANSITION_DURATION);
|
||||
this.photoView.setScaleLevels(ZOOM_LEVEL_MIN, ZOOM_LEVEL_MID, ZOOM_LEVEL_MAX);
|
||||
|
||||
this.subsamplingImageView.setDoubleTapZoomDuration(ZOOM_TRANSITION_DURATION);
|
||||
this.subsamplingImageView.setDoubleTapZoomScale(ZOOM_LEVEL_MID);
|
||||
|
||||
this.photoView.setOnClickListener(v -> ZoomingImageView.this.callOnClick());
|
||||
this.subsamplingImageView.setOnClickListener(v -> ZoomingImageView.this.callOnClick());
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.widget.ImageView;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.util.ResUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
@@ -48,6 +49,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
|
||||
@Override
|
||||
public void onEmojiSelected(String emoji) {
|
||||
recentModel.onCodePointSelected(emoji);
|
||||
SignalStore.emojiValues().setPreferredVariation(emoji);
|
||||
|
||||
if (emojiEventListener != null) {
|
||||
emojiEventListener.onEmojiSelected(emoji);
|
||||
|
||||
@@ -2,23 +2,33 @@ package org.thoughtcrime.securesms.components.emoji;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import android.text.Annotation;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.util.TypedValue;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.AppCompatTextView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiProvider.EmojiDrawable;
|
||||
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.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
public class EmojiTextView extends AppCompatTextView {
|
||||
|
||||
@@ -35,6 +45,9 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private int maxLength;
|
||||
private CharSequence overflowText;
|
||||
private CharSequence previousOverflowText;
|
||||
private boolean renderMentions;
|
||||
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
|
||||
public EmojiTextView(Context context) {
|
||||
this(context, null);
|
||||
@@ -48,14 +61,33 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
super(context, attrs, defStyleAttr);
|
||||
|
||||
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.EmojiTextView, 0, 0);
|
||||
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
|
||||
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
|
||||
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
scaleEmojis = a.getBoolean(R.styleable.EmojiTextView_scaleEmojis, false);
|
||||
maxLength = a.getInteger(R.styleable.EmojiTextView_emoji_maxLength, -1);
|
||||
forceCustom = a.getBoolean(R.styleable.EmojiTextView_emoji_forceCustom, false);
|
||||
renderMentions = a.getBoolean(R.styleable.EmojiTextView_emoji_renderMentions, true);
|
||||
a.recycle();
|
||||
|
||||
a = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.textSize});
|
||||
originalFontSize = a.getDimensionPixelSize(0, 0);
|
||||
a.recycle();
|
||||
|
||||
if (renderMentions) {
|
||||
mentionRendererDelegate = new MentionRendererDelegate(getContext(), ContextCompat.getColor(getContext(), R.color.transparent_black_20));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas canvas) {
|
||||
if (renderMentions && getText() instanceof Spanned && getLayout() != null) {
|
||||
int checkpoint = canvas.save();
|
||||
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
|
||||
try {
|
||||
mentionRendererDelegate.draw(canvas, (Spanned) getText(), getLayout());
|
||||
} finally {
|
||||
canvas.restoreToCount(checkpoint);
|
||||
}
|
||||
}
|
||||
super.onDraw(canvas);
|
||||
}
|
||||
|
||||
@Override public void setText(@Nullable CharSequence text, BufferType type) {
|
||||
@@ -115,7 +147,19 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
private void ellipsizeAnyTextForMaxLength() {
|
||||
if (maxLength > 0 && getText().length() > maxLength + 1) {
|
||||
SpannableStringBuilder newContent = new SpannableStringBuilder();
|
||||
newContent.append(getText().subSequence(0, maxLength)).append(ELLIPSIS).append(Optional.fromNullable(overflowText).or(""));
|
||||
|
||||
CharSequence shortenedText = getText().subSequence(0, maxLength);
|
||||
if (shortenedText instanceof Spanned) {
|
||||
Spanned spanned = (Spanned) shortenedText;
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(spanned, maxLength - 1, maxLength);
|
||||
if (!mentionAnnotations.isEmpty()) {
|
||||
shortenedText = shortenedText.subSequence(0, spanned.getSpanStart(mentionAnnotations.get(0)));
|
||||
}
|
||||
}
|
||||
|
||||
newContent.append(shortenedText)
|
||||
.append(ELLIPSIS)
|
||||
.append(Util.emptyIfNull(overflowText));
|
||||
|
||||
EmojiParser.CandidateList newCandidates = EmojiProvider.getInstance(getContext()).getCandidates(newContent);
|
||||
|
||||
@@ -198,4 +242,10 @@ public class EmojiTextView extends AppCompatTextView {
|
||||
this.originalFontSize = TypedValue.applyDimension(unit, size, getResources().getDisplayMetrics());
|
||||
super.setTextSize(unit, size);
|
||||
}
|
||||
|
||||
public void setMentionBackgroundTint(@ColorInt int mentionBackgroundTint) {
|
||||
if (renderMentions) {
|
||||
mentionRendererDelegate.setTint(mentionBackgroundTint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.widget.PopupWindow;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -27,7 +28,7 @@ public class EmojiVariationSelectorPopup extends PopupWindow {
|
||||
this.listener = listener;
|
||||
this.list = (ViewGroup) getContentView().findViewById(R.id.emoji_variation_container);
|
||||
|
||||
setBackgroundDrawable(null);
|
||||
setBackgroundDrawable(ThemeUtil.getThemedDrawable(context, R.attr.emoji_variation_selector_background));
|
||||
setOutsideTouchable(true);
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.AsyncTask;
|
||||
import android.preference.PreferenceManager;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
@@ -13,6 +15,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
@@ -73,6 +76,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void onCodePointSelected(String emoji) {
|
||||
recentlyUsed.remove(emoji);
|
||||
recentlyUsed.add(emoji);
|
||||
@@ -84,22 +88,16 @@ public class RecentEmojiPageModel implements EmojiPageModel {
|
||||
}
|
||||
|
||||
final LinkedHashSet<String> latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed);
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
|
||||
@Override
|
||||
protected Void doInBackground(Void... params) {
|
||||
try {
|
||||
String serialized = JsonUtils.toJson(latestRecentlyUsed);
|
||||
prefs.edit()
|
||||
.putString(preferenceName, serialized)
|
||||
.apply();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
return null;
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
try {
|
||||
String serialized = JsonUtils.toJson(latestRecentlyUsed);
|
||||
prefs.edit()
|
||||
.putString(preferenceName, serialized)
|
||||
.apply();
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
||||
});
|
||||
}
|
||||
|
||||
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.Spannable;
|
||||
import android.text.Spanned;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* This wraps an Android standard {@link Annotation} so it can leverage the built in
|
||||
* span parceling for copy/paste. The annotation span contains the mentioned recipient's
|
||||
* id (in numerical form).
|
||||
*
|
||||
* Note: Do not extend Annotation or the parceling behavior will be lost.
|
||||
*/
|
||||
public final class MentionAnnotation {
|
||||
|
||||
public static final String MENTION_ANNOTATION = "mention";
|
||||
|
||||
private MentionAnnotation() {
|
||||
}
|
||||
|
||||
public static Annotation mentionAnnotationForRecipientId(@NonNull RecipientId id) {
|
||||
return new Annotation(MENTION_ANNOTATION, idToMentionAnnotationValue(id));
|
||||
}
|
||||
|
||||
public static String idToMentionAnnotationValue(@NonNull RecipientId id) {
|
||||
return String.valueOf(id.toLong());
|
||||
}
|
||||
|
||||
public static boolean isMentionAnnotation(@NonNull Annotation annotation) {
|
||||
return MENTION_ANNOTATION.equals(annotation.getKey());
|
||||
}
|
||||
|
||||
public static void setMentionAnnotations(Spannable body, List<Mention> mentions) {
|
||||
for (Mention mention : mentions) {
|
||||
body.setSpan(MentionAnnotation.mentionAnnotationForRecipientId(mention.getRecipientId()), mention.getStart(), mention.getStart() + mention.getLength(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull List<Mention> getMentionsFromAnnotations(@Nullable CharSequence text) {
|
||||
if (text instanceof Spanned) {
|
||||
Spanned spanned = (Spanned) text;
|
||||
return Stream.of(getMentionAnnotations(spanned))
|
||||
.map(annotation -> {
|
||||
int spanStart = spanned.getSpanStart(annotation);
|
||||
int spanLength = spanned.getSpanEnd(annotation) - spanStart;
|
||||
return new Mention(RecipientId.from(annotation.getValue()), spanStart, spanLength);
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned) {
|
||||
return getMentionAnnotations(spanned, 0, spanned.length());
|
||||
}
|
||||
|
||||
public static @NonNull List<Annotation> getMentionAnnotations(@NonNull Spanned spanned, int start, int end) {
|
||||
return Stream.of(spanned.getSpans(start, end, Annotation.class))
|
||||
.filter(MentionAnnotation::isMentionAnnotation)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextWatcher;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
||||
|
||||
/**
|
||||
* Detects if some part of the mention is being deleted, and if so, deletes the entire mention and
|
||||
* span from the text view.
|
||||
*/
|
||||
public class MentionDeleter implements TextWatcher {
|
||||
|
||||
@Nullable private Annotation toDelete;
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence sequence, int start, int count, int after) {
|
||||
if (count > 0 && sequence instanceof Spanned) {
|
||||
Spanned text = (Spanned) sequence;
|
||||
|
||||
for (Annotation annotation : MentionAnnotation.getMentionAnnotations(text, start, start + count)) {
|
||||
if (text.getSpanStart(annotation) < start && text.getSpanEnd(annotation) > start) {
|
||||
toDelete = annotation;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
if (toDelete == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
int toDeleteStart = editable.getSpanStart(toDelete);
|
||||
int toDeleteEnd = editable.getSpanEnd(toDelete);
|
||||
editable.removeSpan(toDelete);
|
||||
toDelete = null;
|
||||
|
||||
editable.replace(toDeleteStart, toDeleteEnd, String.valueOf(MENTION_STARTER));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence sequence, int start, int before, int count) { }
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Layout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.LayoutUtil;
|
||||
|
||||
/**
|
||||
* Handles actually drawing the mention backgrounds for a TextView.
|
||||
* <p>
|
||||
* Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
|
||||
*/
|
||||
public abstract class MentionRenderer {
|
||||
|
||||
protected final int horizontalPadding;
|
||||
protected final int verticalPadding;
|
||||
|
||||
public MentionRenderer(int horizontalPadding, int verticalPadding) {
|
||||
this.horizontalPadding = horizontalPadding;
|
||||
this.verticalPadding = verticalPadding;
|
||||
}
|
||||
|
||||
public abstract void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset);
|
||||
|
||||
protected int getLineTop(@NonNull Layout layout, int line) {
|
||||
return LayoutUtil.getLineTopWithoutPadding(layout, line) - verticalPadding;
|
||||
}
|
||||
|
||||
protected int getLineBottom(@NonNull Layout layout, int line) {
|
||||
return LayoutUtil.getLineBottomWithoutPadding(layout, line) + verticalPadding;
|
||||
}
|
||||
|
||||
public static final class SingleLineMentionRenderer extends MentionRenderer {
|
||||
|
||||
private final Drawable drawable;
|
||||
|
||||
public SingleLineMentionRenderer(int horizontalPadding, int verticalPadding, @NonNull Drawable drawable) {
|
||||
super(horizontalPadding, verticalPadding);
|
||||
this.drawable = drawable;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
|
||||
int lineTop = getLineTop(layout, startLine);
|
||||
int lineBottom = getLineBottom(layout, startLine);
|
||||
int left = Math.min(startOffset, endOffset);
|
||||
int right = Math.max(startOffset, endOffset);
|
||||
|
||||
drawable.setBounds(left, lineTop, right, lineBottom);
|
||||
drawable.draw(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class MultiLineMentionRenderer extends MentionRenderer {
|
||||
|
||||
private final Drawable drawableLeft;
|
||||
private final Drawable drawableMid;
|
||||
private final Drawable drawableRight;
|
||||
|
||||
public MultiLineMentionRenderer(int horizontalPadding, int verticalPadding,
|
||||
@NonNull Drawable drawableLeft,
|
||||
@NonNull Drawable drawableMid,
|
||||
@NonNull Drawable drawableRight)
|
||||
{
|
||||
super(horizontalPadding, verticalPadding);
|
||||
this.drawableLeft = drawableLeft;
|
||||
this.drawableMid = drawableMid;
|
||||
this.drawableRight = drawableRight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
|
||||
int paragraphDirection = layout.getParagraphDirection(startLine);
|
||||
|
||||
float lineEndOffset;
|
||||
if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
|
||||
lineEndOffset = layout.getLineLeft(startLine) - horizontalPadding;
|
||||
} else {
|
||||
lineEndOffset = layout.getLineRight(startLine) + horizontalPadding;
|
||||
}
|
||||
|
||||
int lineBottom = getLineBottom(layout, startLine);
|
||||
int lineTop = getLineTop(layout, startLine);
|
||||
drawStart(canvas, startOffset, lineTop, (int) lineEndOffset, lineBottom);
|
||||
|
||||
for (int line = startLine + 1; line < endLine; line++) {
|
||||
int left = (int) layout.getLineLeft(line) - horizontalPadding;
|
||||
int right = (int) layout.getLineRight(line) + horizontalPadding;
|
||||
|
||||
lineTop = getLineTop(layout, line);
|
||||
lineBottom = getLineBottom(layout, line);
|
||||
|
||||
drawableMid.setBounds(left, lineTop, right, lineBottom);
|
||||
drawableMid.draw(canvas);
|
||||
}
|
||||
|
||||
float lineStartOffset;
|
||||
if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
|
||||
lineStartOffset = layout.getLineRight(startLine) + horizontalPadding;
|
||||
} else {
|
||||
lineStartOffset = layout.getLineLeft(startLine) - horizontalPadding;
|
||||
}
|
||||
|
||||
lineBottom = getLineBottom(layout, endLine);
|
||||
lineTop = getLineTop(layout, endLine);
|
||||
|
||||
drawEnd(canvas, (int) lineStartOffset, lineTop, endOffset, lineBottom);
|
||||
}
|
||||
|
||||
private void drawStart(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
|
||||
if (start > end) {
|
||||
drawableRight.setBounds(end, top, start, bottom);
|
||||
drawableRight.draw(canvas);
|
||||
} else {
|
||||
drawableLeft.setBounds(start, top, end, bottom);
|
||||
drawableLeft.draw(canvas);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawEnd(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
|
||||
if (start > end) {
|
||||
drawableLeft.setBounds(end, top, start, bottom);
|
||||
drawableLeft.draw(canvas);
|
||||
} else {
|
||||
drawableRight.setBounds(start, top, end, bottom);
|
||||
drawableRight.draw(canvas);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Annotation;
|
||||
import android.text.Layout;
|
||||
import android.text.Spanned;
|
||||
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for determining the type of mention rendering needed (single vs multi-line) and then
|
||||
* passing that information to the appropriate {@link MentionRenderer}.
|
||||
* <p></p>
|
||||
* Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
|
||||
*/
|
||||
public class MentionRendererDelegate {
|
||||
|
||||
private final MentionRenderer single;
|
||||
private final MentionRenderer multi;
|
||||
private final int horizontalPadding;
|
||||
private final Drawable drawable;
|
||||
private final Drawable drawableLeft;
|
||||
private final Drawable drawableMid;
|
||||
private final Drawable drawableEnd;
|
||||
|
||||
public MentionRendererDelegate(@NonNull Context context, @ColorInt int tint) {
|
||||
this.horizontalPadding = ViewUtil.dpToPx(2);
|
||||
|
||||
drawable = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg), tint);
|
||||
drawableLeft = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg_left), tint);
|
||||
drawableMid = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg_mid), tint);
|
||||
drawableEnd = DrawableUtil.tint(ContextUtil.requireDrawable(context, R.drawable.mention_text_bg_right), tint);
|
||||
|
||||
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
|
||||
0,
|
||||
drawable);
|
||||
|
||||
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
|
||||
0,
|
||||
drawableLeft,
|
||||
drawableMid,
|
||||
drawableEnd);
|
||||
}
|
||||
|
||||
public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) {
|
||||
Annotation[] annotations = text.getSpans(0, text.length(), Annotation.class);
|
||||
for (Annotation annotation : annotations) {
|
||||
if (MentionAnnotation.isMentionAnnotation(annotation)) {
|
||||
int spanStart = text.getSpanStart(annotation);
|
||||
int spanEnd = text.getSpanEnd(annotation);
|
||||
int startLine = layout.getLineForOffset(spanStart);
|
||||
int endLine = layout.getLineForOffset(spanEnd);
|
||||
|
||||
int startOffset = (int) (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine) * horizontalPadding);
|
||||
int endOffset = (int) (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine) * horizontalPadding);
|
||||
|
||||
MentionRenderer renderer = (startLine == endLine) ? single : multi;
|
||||
renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setTint(@ColorInt int tint) {
|
||||
DrawableCompat.setTint(drawable, tint);
|
||||
DrawableCompat.setTint(drawableLeft, tint);
|
||||
DrawableCompat.setTint(drawableMid, tint);
|
||||
DrawableCompat.setTint(drawableEnd, tint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.components.mention;
|
||||
|
||||
import android.text.Annotation;
|
||||
import android.text.Editable;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextWatcher;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Provides a mechanism to validate mention annotations set on an edit text. This enables
|
||||
* removing invalid mentions if the user mentioned isn't in the group.
|
||||
*/
|
||||
public class MentionValidatorWatcher implements TextWatcher {
|
||||
|
||||
@Nullable private List<Annotation> invalidMentionAnnotations;
|
||||
@Nullable private MentionValidator mentionValidator;
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence sequence, int start, int before, int count) {
|
||||
if (count > 1 && mentionValidator != null && sequence instanceof Spanned) {
|
||||
Spanned span = (Spanned) sequence;
|
||||
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(span, start, start + count);
|
||||
|
||||
if (mentionAnnotations.size() > 0) {
|
||||
invalidMentionAnnotations = mentionValidator.getInvalidMentionAnnotations(mentionAnnotations);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable editable) {
|
||||
if (invalidMentionAnnotations == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<Annotation> invalidMentions = invalidMentionAnnotations;
|
||||
invalidMentionAnnotations = null;
|
||||
|
||||
for (Annotation annotation : invalidMentions) {
|
||||
editable.removeSpan(annotation);
|
||||
}
|
||||
}
|
||||
|
||||
public void setMentionValidator(@Nullable MentionValidator mentionValidator) {
|
||||
this.mentionValidator = mentionValidator;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence sequence, int start, int count, int after) { }
|
||||
|
||||
public interface MentionValidator {
|
||||
List<Annotation> getInvalidMentionAnnotations(List<Annotation> mentionAnnotations);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.reminder;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
public class ExpiredBuildReminder extends Reminder {
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = ExpiredBuildReminder.class.getSimpleName();
|
||||
|
||||
public ExpiredBuildReminder(final Context context) {
|
||||
super(context.getString(R.string.reminder_header_expired_build),
|
||||
context.getString(R.string.reminder_header_expired_build_details));
|
||||
setOkListener(v -> {
|
||||
try {
|
||||
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + context.getPackageName())));
|
||||
} catch (android.content.ActivityNotFoundException anfe) {
|
||||
try {
|
||||
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=" + context.getPackageName())));
|
||||
} catch (android.content.ActivityNotFoundException anfe2) {
|
||||
Log.w(TAG, anfe2);
|
||||
Toast.makeText(context, R.string.OutdatedBuildReminder_no_web_browser_installed, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
});
|
||||
setOkListener(v -> PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.reminder;
|
||||
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
|
||||
import android.widget.Toast;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
public class OutdatedBuildReminder extends Reminder {
|
||||
|
||||
private static final String TAG = OutdatedBuildReminder.class.getSimpleName();
|
||||
|
||||
public OutdatedBuildReminder(final Context context) {
|
||||
super(context.getString(R.string.reminder_header_outdated_build),
|
||||
getPluralsText(context));
|
||||
setOkListener(v -> {
|
||||
try {
|
||||
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + context.getPackageName())));
|
||||
} catch (ActivityNotFoundException anfe) {
|
||||
try {
|
||||
context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=" + context.getPackageName())));
|
||||
} catch (ActivityNotFoundException anfe2) {
|
||||
Log.w(TAG, anfe2);
|
||||
Toast.makeText(context, R.string.OutdatedBuildReminder_no_web_browser_installed, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
});
|
||||
setOkListener(v -> PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context));
|
||||
}
|
||||
|
||||
private static CharSequence getPluralsText(final Context context) {
|
||||
|
||||
@@ -286,6 +286,7 @@ public class WebRtcCallView extends FrameLayout {
|
||||
public void setStatusFromHangupType(@NonNull HangupMessage.Type hangupType) {
|
||||
switch (hangupType) {
|
||||
case NORMAL:
|
||||
case NEED_PERMISSION:
|
||||
status.setText(R.string.RedPhone_ending_call);
|
||||
break;
|
||||
case ACCEPTED:
|
||||
@@ -306,7 +307,10 @@ public class WebRtcCallView extends FrameLayout {
|
||||
Set<View> lastVisibleSet = new HashSet<>(visibleViewSet);
|
||||
|
||||
visibleViewSet.clear();
|
||||
visibleViewSet.addAll(topViews);
|
||||
|
||||
if (webRtcControls.displayTopViews()) {
|
||||
visibleViewSet.addAll(topViews);
|
||||
}
|
||||
|
||||
if (webRtcControls.displayIncomingCallButtons()) {
|
||||
visibleViewSet.addAll(incomingCallViews);
|
||||
|
||||
@@ -180,6 +180,7 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
isRemoteVideoEnabled || isRemoteVideoOffer,
|
||||
isMoreThanOneCameraAvailable,
|
||||
isBluetoothAvailable,
|
||||
isInPipMode.getValue() == Boolean.TRUE,
|
||||
callState,
|
||||
audioOutput));
|
||||
}
|
||||
@@ -189,9 +190,9 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
else return WebRtcLocalRenderState.GONE;
|
||||
}
|
||||
|
||||
private @NonNull WebRtcControls getRealWebRtcControls(boolean neverDisplayControls, @NonNull WebRtcControls controls) {
|
||||
if (neverDisplayControls) return WebRtcControls.NONE;
|
||||
else return controls;
|
||||
private @NonNull WebRtcControls getRealWebRtcControls(boolean isInPipMode, @NonNull WebRtcControls controls) {
|
||||
if (isInPipMode) return WebRtcControls.PIP;
|
||||
else return controls;
|
||||
}
|
||||
|
||||
private void startTimer() {
|
||||
|
||||
@@ -5,22 +5,25 @@ import androidx.annotation.NonNull;
|
||||
public final class WebRtcControls {
|
||||
|
||||
public static final WebRtcControls NONE = new WebRtcControls();
|
||||
public static final WebRtcControls PIP = new WebRtcControls(false, false, false, false, true, CallState.NONE, WebRtcAudioOutput.HANDSET);
|
||||
|
||||
private final boolean isRemoteVideoEnabled;
|
||||
private final boolean isLocalVideoEnabled;
|
||||
private final boolean isMoreThanOneCameraAvailable;
|
||||
private final boolean isBluetoothAvailable;
|
||||
private final boolean isInPipMode;
|
||||
private final CallState callState;
|
||||
private final WebRtcAudioOutput audioOutput;
|
||||
|
||||
private WebRtcControls() {
|
||||
this(false, false, false, false, CallState.NONE, WebRtcAudioOutput.HANDSET);
|
||||
this(false, false, false, false, false, CallState.NONE, WebRtcAudioOutput.HANDSET);
|
||||
}
|
||||
|
||||
WebRtcControls(boolean isLocalVideoEnabled,
|
||||
boolean isRemoteVideoEnabled,
|
||||
boolean isMoreThanOneCameraAvailable,
|
||||
boolean isBluetoothAvailable,
|
||||
boolean isInPipMode,
|
||||
@NonNull CallState callState,
|
||||
@NonNull WebRtcAudioOutput audioOutput)
|
||||
{
|
||||
@@ -28,6 +31,7 @@ public final class WebRtcControls {
|
||||
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
|
||||
this.isBluetoothAvailable = isBluetoothAvailable;
|
||||
this.isMoreThanOneCameraAvailable = isMoreThanOneCameraAvailable;
|
||||
this.isInPipMode = isInPipMode;
|
||||
this.callState = callState;
|
||||
this.audioOutput = audioOutput;
|
||||
}
|
||||
@@ -80,6 +84,10 @@ public final class WebRtcControls {
|
||||
return isOngoing() && !(displayAudioToggle() && displayCameraToggle());
|
||||
}
|
||||
|
||||
boolean displayTopViews() {
|
||||
return !isInPipMode;
|
||||
}
|
||||
|
||||
WebRtcAudioOutput getAudioOutput() {
|
||||
return audioOutput;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package org.thoughtcrime.securesms.contacts;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Activity which displays a dialog to confirm whether to turn off "Contact Joined Signal" notifications.
|
||||
*/
|
||||
public class TurnOffContactJoinedNotificationsActivity extends AppCompatActivity {
|
||||
|
||||
private final static String EXTRA_THREAD_ID = "thread_id";
|
||||
|
||||
public static Intent newIntent(@NonNull Context context, long threadId) {
|
||||
Intent intent = new Intent(context, TurnOffContactJoinedNotificationsActivity.class);
|
||||
|
||||
intent.putExtra(EXTRA_THREAD_ID, threadId);
|
||||
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setMessage(R.string.TurnOffContactJoinedNotificationsActivity__turn_off_contact_joined_signal)
|
||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
|
||||
handlePositiveAction(dialog);
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, ((dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
finish();
|
||||
}))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void handlePositiveAction(@NonNull DialogInterface dialog) {
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(this);
|
||||
|
||||
List<MessagingDatabase.MarkedMessageInfo> marked = threadDatabase.setRead(getIntent().getLongExtra(EXTRA_THREAD_ID, -1), false);
|
||||
MarkReadReceiver.process(this, marked);
|
||||
|
||||
TextSecurePreferences.setNewContactsNotificationEnabled(this, false);
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(this);
|
||||
|
||||
return null;
|
||||
}, unused -> {
|
||||
dialog.dismiss();
|
||||
finish();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper.DirectoryResult;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
class ContactDiscoveryV1 {
|
||||
|
||||
private static final String TAG = ContactDiscoveryV1.class.getSimpleName();
|
||||
|
||||
static @NonNull DirectoryResult getDirectoryResult(@NonNull Set<String> databaseNumbers,
|
||||
@NonNull Set<String> systemNumbers)
|
||||
throws IOException
|
||||
{
|
||||
Set<String> allNumbers = SetUtil.union(databaseNumbers, systemNumbers);
|
||||
FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(allNumbers, databaseNumbers);
|
||||
List<ContactTokenDetails> activeTokens = getTokens(inputResult.getNumbers());
|
||||
Set<String> activeNumbers = Stream.of(activeTokens).map(ContactTokenDetails::getNumber).collect(Collectors.toSet());
|
||||
FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(activeNumbers, inputResult);
|
||||
HashMap<String, UUID> uuids = new HashMap<>();
|
||||
|
||||
for (String number : outputResult.getNumbers()) {
|
||||
uuids.put(number, null);
|
||||
}
|
||||
|
||||
return new DirectoryResult(uuids, outputResult.getRewrites());
|
||||
}
|
||||
|
||||
static @NonNull DirectoryResult getDirectoryResult(@NonNull String number) throws IOException {
|
||||
return getDirectoryResult(Collections.singleton(number), Collections.singleton(number));
|
||||
}
|
||||
|
||||
private static @NonNull List<ContactTokenDetails> getTokens(@NonNull Set<String> numbers) throws IOException {
|
||||
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||
|
||||
if (numbers.size() == 1) {
|
||||
Optional<ContactTokenDetails> details = accountManager.getContact(numbers.iterator().next());
|
||||
return details.isPresent() ? Collections.singletonList(details.get()) : Collections.emptyList();
|
||||
} else {
|
||||
return accountManager.getContacts(numbers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper.DirectoryResult;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.push.IasTrustStore;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.push.TrustStore;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.Quote;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedQuoteException;
|
||||
import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SignatureException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
class ContactDiscoveryV2 {
|
||||
|
||||
private static final String TAG = Log.tag(ContactDiscoveryV2.class);
|
||||
|
||||
@WorkerThread
|
||||
static DirectoryResult getDirectoryResult(@NonNull Context context,
|
||||
@NonNull Set<String> databaseNumbers,
|
||||
@NonNull Set<String> systemNumbers)
|
||||
throws IOException
|
||||
{
|
||||
Set<String> allNumbers = SetUtil.union(databaseNumbers, systemNumbers);
|
||||
FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(allNumbers, databaseNumbers);
|
||||
Set<String> sanitizedNumbers = sanitizeNumbers(inputResult.getNumbers());
|
||||
|
||||
|
||||
SignalServiceAccountManager accountManager = ApplicationDependencies.getSignalServiceAccountManager();
|
||||
KeyStore iasKeyStore = getIasKeyStore(context);
|
||||
|
||||
try {
|
||||
Map<String, UUID> results = accountManager.getRegisteredUsers(iasKeyStore, sanitizedNumbers, BuildConfig.CDS_MRENCLAVE);
|
||||
FuzzyPhoneNumberHelper.OutputResultV2 outputResult = FuzzyPhoneNumberHelper.generateOutputV2(results, inputResult);
|
||||
|
||||
return new DirectoryResult(outputResult.getNumbers(), outputResult.getRewrites());
|
||||
} catch (SignatureException | UnauthenticatedQuoteException | UnauthenticatedResponseException | Quote.InvalidQuoteFormatException e) {
|
||||
Log.w(TAG, "Attestation error.", e);
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
static @NonNull DirectoryResult getDirectoryResult(@NonNull Context context, @NonNull String number) throws IOException {
|
||||
return getDirectoryResult(context, Collections.singleton(number), Collections.singleton(number));
|
||||
}
|
||||
|
||||
private static Set<String> sanitizeNumbers(@NonNull Set<String> numbers) {
|
||||
return Stream.of(numbers).filter(number -> {
|
||||
try {
|
||||
return number.startsWith("+") && number.length() > 1 && number.charAt(1) != '0' && Long.parseLong(number.substring(1)) > 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private static KeyStore getIasKeyStore(@NonNull Context context) {
|
||||
try {
|
||||
TrustStore contactTrustStore = new IasTrustStore(context);
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("BKS");
|
||||
keyStore.load(contactTrustStore.getKeyStoreInputStream(), contactTrustStore.getKeyStorePassword().toCharArray());
|
||||
|
||||
return keyStore;
|
||||
} catch (KeyStoreException | CertificateException | IOException | NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,419 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import android.Manifest;
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.OperationApplicationException;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsDatabase;
|
||||
import org.thoughtcrime.securesms.crypto.SessionUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.BulkOperationsHandle;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.StorageSyncJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.IncomingJoinedMessage;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
/**
|
||||
* Manages all the stuff around determining if a user is registered or not.
|
||||
*/
|
||||
public class DirectoryHelper {
|
||||
|
||||
private static final String TAG = Log.tag(DirectoryHelper.class);
|
||||
|
||||
@WorkerThread
|
||||
public static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException {
|
||||
if (FeatureFlags.uuids()) {
|
||||
// TODO [greyson] Create a DirectoryHelperV2 when appropriate.
|
||||
DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers);
|
||||
if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) {
|
||||
Log.w(TAG, "Have not yet set our own local number. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) {
|
||||
Log.w(TAG, "No contact permissions. Skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SignalStore.registrationValues().isRegistrationComplete()) {
|
||||
Log.w(TAG, "Registration is not yet complete. Skipping, but running a routine to possibly mark it complete.");
|
||||
RegistrationUtil.maybeMarkRegistrationComplete(context);
|
||||
return;
|
||||
}
|
||||
|
||||
Stopwatch stopwatch = new Stopwatch("full");
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
Set<String> databaseNumbers = sanitizeNumbers(recipientDatabase.getAllPhoneNumbers());
|
||||
Set<String> systemNumbers = sanitizeNumbers(ContactAccessor.getInstance().getAllContactsWithNumbers(context));
|
||||
Set<String> allNumbers = SetUtil.union(databaseNumbers, systemNumbers);
|
||||
|
||||
DirectoryResult result;
|
||||
|
||||
if (FeatureFlags.cds()) {
|
||||
result = ContactDiscoveryV2.getDirectoryResult(context, databaseNumbers, systemNumbers);
|
||||
} else {
|
||||
DirectoryHelperV1.refreshDirectory(context, notifyOfNewUsers);
|
||||
result = ContactDiscoveryV1.getDirectoryResult(databaseNumbers, systemNumbers);
|
||||
}
|
||||
|
||||
stopwatch.split("network");
|
||||
|
||||
if (result.getNumberRewrites().size() > 0) {
|
||||
Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers.");
|
||||
recipientDatabase.updatePhoneNumbers(result.getNumberRewrites());
|
||||
}
|
||||
|
||||
Map<RecipientId, String> uuidMap = recipientDatabase.bulkProcessCdsResult(result.getRegisteredNumbers());
|
||||
Set<String> activeNumbers = result.getRegisteredNumbers().keySet();
|
||||
Set<RecipientId> activeIds = uuidMap.keySet();
|
||||
Set<RecipientId> inactiveIds = Stream.of(allNumbers)
|
||||
.filterNot(activeNumbers::contains)
|
||||
.filterNot(n -> result.getNumberRewrites().containsKey(n))
|
||||
.map(recipientDatabase::getOrInsertFromE164)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
recipientDatabase.bulkUpdatedRegisteredStatus(uuidMap, inactiveIds);
|
||||
|
||||
updateContactsDatabase(context, activeIds, true, result.getNumberRewrites());
|
||||
|
||||
if (TextSecurePreferences.isMultiDevice(context)) {
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob());
|
||||
}
|
||||
|
||||
if (TextSecurePreferences.hasSuccessfullyRetrievedDirectory(context) && notifyOfNewUsers) {
|
||||
Set<RecipientId> existingSignalIds = new HashSet<>(recipientDatabase.getRegistered());
|
||||
Set<RecipientId> existingSystemIds = new HashSet<>(recipientDatabase.getSystemContacts());
|
||||
Set<RecipientId> newlyActiveIds = new HashSet<>(activeIds);
|
||||
|
||||
newlyActiveIds.removeAll(existingSignalIds);
|
||||
newlyActiveIds.retainAll(existingSystemIds);
|
||||
|
||||
notifyNewUsers(context, newlyActiveIds);
|
||||
} else {
|
||||
TextSecurePreferences.setHasSuccessfullyRetrievedDirectory(context, true);
|
||||
}
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
|
||||
stopwatch.split("disk");
|
||||
stopwatch.stop(TAG);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException {
|
||||
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
|
||||
RegisteredState newRegisteredState = null;
|
||||
Stopwatch stopwatch = new Stopwatch("single");
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
RegisteredState originalRegisteredState = recipient.resolve().getRegistered();
|
||||
RegisteredState newRegisteredState = null;
|
||||
|
||||
if (FeatureFlags.uuids()) {
|
||||
// TODO [greyson] Create a DirectoryHelperV2 when appropriate.
|
||||
newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
} else {
|
||||
newRegisteredState = DirectoryHelperV1.refreshDirectoryFor(context, recipient, notifyOfNewUsers);
|
||||
if (recipient.hasUuid() && !recipient.hasE164()) {
|
||||
boolean isRegistered = isUuidRegistered(context, recipient);
|
||||
stopwatch.split("uuid-network");
|
||||
if (isRegistered) {
|
||||
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get());
|
||||
if (idChanged) {
|
||||
Log.w(TAG, "ID changed during refresh by UUID.");
|
||||
}
|
||||
} else {
|
||||
recipientDatabase.markUnregistered(recipient.getId());
|
||||
}
|
||||
|
||||
stopwatch.split("uuid-disk");
|
||||
stopwatch.stop(TAG);
|
||||
|
||||
return isRegistered ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED;
|
||||
}
|
||||
|
||||
if (!recipient.getE164().isPresent()) {
|
||||
Log.w(TAG, "No UUID or E164?");
|
||||
return RegisteredState.NOT_REGISTERED;
|
||||
}
|
||||
|
||||
DirectoryResult result;
|
||||
|
||||
if (FeatureFlags.cds()) {
|
||||
result = ContactDiscoveryV2.getDirectoryResult(context, recipient.getE164().get());
|
||||
} else {
|
||||
result = ContactDiscoveryV1.getDirectoryResult(recipient.getE164().get());
|
||||
}
|
||||
|
||||
stopwatch.split("e164-network");
|
||||
|
||||
if (result.getNumberRewrites().size() > 0) {
|
||||
Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers.");
|
||||
recipientDatabase.updatePhoneNumbers(result.getNumberRewrites());
|
||||
}
|
||||
|
||||
if (result.getRegisteredNumbers().size() > 0) {
|
||||
UUID uuid = result.getRegisteredNumbers().values().iterator().next();
|
||||
if (uuid != null) {
|
||||
boolean idChanged = recipientDatabase.markRegistered(recipient.getId(), uuid);
|
||||
if (idChanged) {
|
||||
recipient = Recipient.resolved(recipientDatabase.getByUuid(uuid).get());
|
||||
}
|
||||
} else {
|
||||
recipientDatabase.markRegistered(recipient.getId());
|
||||
}
|
||||
} else {
|
||||
recipientDatabase.markUnregistered(recipient.getId());
|
||||
}
|
||||
|
||||
if (Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) {
|
||||
updateContactsDatabase(context, Collections.singletonList(recipient.getId()), false, result.getNumberRewrites());
|
||||
}
|
||||
|
||||
newRegisteredState = result.getRegisteredNumbers().size() > 0 ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED;
|
||||
|
||||
if (newRegisteredState != originalRegisteredState) {
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob());
|
||||
ApplicationDependencies.getJobManager().add(new StorageSyncJob());
|
||||
|
||||
if (notifyOfNewUsers && newRegisteredState == RegisteredState.REGISTERED && recipient.resolve().isSystemContact()) {
|
||||
notifyNewUsers(context, Collections.singletonList(recipient.getId()));
|
||||
}
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
}
|
||||
|
||||
stopwatch.split("e164-disk");
|
||||
stopwatch.stop(TAG);
|
||||
|
||||
return newRegisteredState;
|
||||
}
|
||||
|
||||
private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException {
|
||||
try {
|
||||
ProfileUtil.retrieveProfile(context, recipient, SignalServiceProfile.RequestType.PROFILE).get(10, TimeUnit.SECONDS);
|
||||
return true;
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof NotFoundException) {
|
||||
return false;
|
||||
} else {
|
||||
throw new IOException(e);
|
||||
}
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updateContactsDatabase(@NonNull Context context,
|
||||
@NonNull Collection<RecipientId> activeIds,
|
||||
boolean removeMissing,
|
||||
@NonNull Map<String, String> rewrites)
|
||||
{
|
||||
AccountHolder account = getOrCreateSystemAccount(context);
|
||||
|
||||
if (account == null) {
|
||||
Log.w(TAG, "Failed to create an account!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
ContactsDatabase contactsDatabase = DatabaseFactory.getContactsDatabase(context);
|
||||
List<String> activeAddresses = Stream.of(activeIds)
|
||||
.map(Recipient::resolved)
|
||||
.filter(Recipient::hasE164)
|
||||
.map(Recipient::requireE164)
|
||||
.toList();
|
||||
|
||||
contactsDatabase.removeDeletedRawContacts(account.getAccount());
|
||||
contactsDatabase.setRegisteredUsers(account.getAccount(), activeAddresses, removeMissing);
|
||||
|
||||
Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context);
|
||||
BulkOperationsHandle handle = recipientDatabase.beginBulkSystemContactUpdate();
|
||||
|
||||
try {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER));
|
||||
|
||||
if (isValidContactNumber(number)) {
|
||||
String formattedNumber = PhoneNumberFormatter.get(context).format(number);
|
||||
String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber);
|
||||
RecipientId recipientId = Recipient.externalContact(context, realNumber).getId();
|
||||
String displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
|
||||
String contactPhotoUri = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.PHOTO_URI));
|
||||
String contactLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL));
|
||||
int phoneType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE));
|
||||
Uri contactUri = ContactsContract.Contacts.getLookupUri(cursor.getLong(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone._ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)));
|
||||
|
||||
handle.setSystemContactInfo(recipientId, displayName, contactPhotoUri, contactLabel, phoneType, contactUri.toString());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
handle.finish();
|
||||
}
|
||||
|
||||
if (NotificationChannels.supported()) {
|
||||
try (RecipientDatabase.RecipientReader recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsWithNotificationChannels()) {
|
||||
Recipient recipient;
|
||||
while ((recipient = recipients.getNext()) != null) {
|
||||
NotificationChannels.updateContactChannelName(context, recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RemoteException | OperationApplicationException e) {
|
||||
Log.w(TAG, "Failed to update contacts.", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidContactNumber(@Nullable String number) {
|
||||
return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number);
|
||||
}
|
||||
|
||||
private static @Nullable AccountHolder getOrCreateSystemAccount(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account[] accounts = accountManager.getAccountsByType(BuildConfig.APPLICATION_ID);
|
||||
|
||||
AccountHolder account;
|
||||
|
||||
if (accounts.length == 0) {
|
||||
account = createAccount(context);
|
||||
} else {
|
||||
account = new AccountHolder(accounts[0], false);
|
||||
}
|
||||
|
||||
if (account != null && !ContentResolver.getSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY)) {
|
||||
ContentResolver.setSyncAutomatically(account.getAccount(), ContactsContract.AUTHORITY, true);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
private static @Nullable AccountHolder createAccount(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account account = new Account(context.getString(R.string.app_name), BuildConfig.APPLICATION_ID);
|
||||
|
||||
if (accountManager.addAccountExplicitly(account, null, null)) {
|
||||
Log.i(TAG, "Created new account...");
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1);
|
||||
return new AccountHolder(account, true);
|
||||
} else {
|
||||
Log.w(TAG, "Failed to create account!");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void notifyNewUsers(@NonNull Context context,
|
||||
@NonNull Collection<RecipientId> newUsers)
|
||||
{
|
||||
if (!TextSecurePreferences.isNewContactsNotificationEnabled(context)) return;
|
||||
|
||||
for (RecipientId newUser: newUsers) {
|
||||
Recipient recipient = Recipient.resolved(newUser);
|
||||
if (!SessionUtil.hasSession(context, recipient.getId()) && !recipient.isLocalNumber()) {
|
||||
IncomingJoinedMessage message = new IncomingJoinedMessage(newUser);
|
||||
Optional<InsertResult> insertResult = DatabaseFactory.getSmsDatabase(context).insertMessageInbox(message);
|
||||
|
||||
if (insertResult.isPresent()) {
|
||||
int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
|
||||
if (hour >= 9 && hour < 23) {
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), true);
|
||||
} else {
|
||||
Log.i(TAG, "Not notifying of a new user due to the time of day. (Hour: " + hour + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Set<String> sanitizeNumbers(@NonNull Set<String> numbers) {
|
||||
return Stream.of(numbers).filter(number -> {
|
||||
try {
|
||||
return number.startsWith("+") && number.length() > 1 && number.charAt(1) != '0' && Long.parseLong(number.substring(1)) > 0;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}).collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
static class DirectoryResult {
|
||||
private final Map<String, UUID> registeredNumbers;
|
||||
private final Map<String, String> numberRewrites;
|
||||
|
||||
DirectoryResult(@NonNull Map<String, UUID> registeredNumbers,
|
||||
@NonNull Map<String, String> numberRewrites)
|
||||
{
|
||||
this.registeredNumbers = registeredNumbers;
|
||||
this.numberRewrites = numberRewrites;
|
||||
}
|
||||
|
||||
|
||||
@NonNull Map<String, UUID> getRegisteredNumbers() {
|
||||
return registeredNumbers;
|
||||
}
|
||||
|
||||
@NonNull Map<String, String> getNumberRewrites() {
|
||||
return numberRewrites;
|
||||
}
|
||||
}
|
||||
|
||||
private static class AccountHolder {
|
||||
private final boolean fresh;
|
||||
private final Account account;
|
||||
|
||||
private AccountHolder(Account account, boolean fresh) {
|
||||
this.fresh = fresh;
|
||||
this.account = account;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public boolean isFresh() {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
package org.thoughtcrime.securesms.contacts.sync;
|
||||
|
||||
import android.Manifest;
|
||||
import android.accounts.Account;
|
||||
import android.accounts.AccountManager;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
import android.content.OperationApplicationException;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.RemoteException;
|
||||
import android.provider.ContactsContract;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.ContactAccessor;
|
||||
import org.thoughtcrime.securesms.crypto.SessionUtil;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.IncomingJoinedMessage;
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile;
|
||||
import org.whispersystems.signalservice.api.push.ContactTokenDetails;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
class DirectoryHelperV1 {
|
||||
|
||||
private static final String TAG = DirectoryHelperV1.class.getSimpleName();
|
||||
|
||||
@WorkerThread
|
||||
static void refreshDirectory(@NonNull Context context, boolean notifyOfNewUsers) throws IOException {
|
||||
if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) return;
|
||||
if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) return;
|
||||
|
||||
List<RecipientId> newlyActiveUsers = refreshDirectory(context, ApplicationDependencies.getSignalServiceAccountManager());
|
||||
|
||||
if (TextSecurePreferences.isMultiDevice(context)) {
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob());
|
||||
}
|
||||
|
||||
if (notifyOfNewUsers) notifyNewUsers(context, newlyActiveUsers);
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private static @NonNull List<RecipientId> refreshDirectory(@NonNull Context context, @NonNull SignalServiceAccountManager accountManager) throws IOException {
|
||||
if (TextUtils.isEmpty(TextSecurePreferences.getLocalNumber(context))) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
if (!Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
Set<String> allRecipientNumbers = recipientDatabase.getAllPhoneNumbers();
|
||||
Stream<String> eligibleRecipientDatabaseContactNumbers = Stream.of(allRecipientNumbers);
|
||||
Stream<String> eligibleSystemDatabaseContactNumbers = Stream.of(ContactAccessor.getInstance().getAllContactsWithNumbers(context));
|
||||
Set<String> eligibleContactNumbers = Stream.concat(eligibleRecipientDatabaseContactNumbers, eligibleSystemDatabaseContactNumbers).collect(Collectors.toSet());
|
||||
Set<String> storedNumbers = Stream.of(allRecipientNumbers).collect(Collectors.toSet());
|
||||
DirectoryResult directoryResult = getDirectoryResult(context, accountManager, recipientDatabase, storedNumbers, eligibleContactNumbers);
|
||||
|
||||
return directoryResult.getNewlyActiveRecipients();
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
static RegisteredState refreshDirectoryFor(@NonNull Context context, @NonNull Recipient recipient, boolean notifyOfNewUsers) throws IOException {
|
||||
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
|
||||
if (recipient.getUuid().isPresent() && !recipient.getE164().isPresent()) {
|
||||
boolean isRegistered = isUuidRegistered(context, recipient);
|
||||
if (isRegistered) {
|
||||
recipientDatabase.markRegistered(recipient.getId(), recipient.getUuid().get());
|
||||
} else {
|
||||
recipientDatabase.markUnregistered(recipient.getId());
|
||||
}
|
||||
|
||||
return isRegistered ? RegisteredState.REGISTERED : RegisteredState.NOT_REGISTERED;
|
||||
}
|
||||
|
||||
return getRegisteredState(context, ApplicationDependencies.getSignalServiceAccountManager(), recipientDatabase, recipient);
|
||||
}
|
||||
|
||||
private static void updateContactsDatabase(@NonNull Context context, @NonNull List<RecipientId> activeIds, boolean removeMissing, Map<String, String> rewrites) {
|
||||
Optional<AccountHolder> account = getOrCreateAccount(context);
|
||||
|
||||
if (account.isPresent()) {
|
||||
try {
|
||||
List<String> activeAddresses = Stream.of(activeIds).map(Recipient::resolved).filter(Recipient::hasE164).map(Recipient::requireE164).toList();
|
||||
|
||||
DatabaseFactory.getContactsDatabase(context).removeDeletedRawContacts(account.get().getAccount());
|
||||
DatabaseFactory.getContactsDatabase(context).setRegisteredUsers(account.get().getAccount(), activeAddresses, removeMissing);
|
||||
|
||||
Cursor cursor = ContactAccessor.getInstance().getAllSystemContacts(context);
|
||||
RecipientDatabase.BulkOperationsHandle handle = DatabaseFactory.getRecipientDatabase(context).beginBulkSystemContactUpdate();
|
||||
|
||||
try {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER));
|
||||
|
||||
if (isValidContactNumber(number)) {
|
||||
String formattedNumber = PhoneNumberFormatter.get(context).format(number);
|
||||
String realNumber = Util.getFirstNonEmpty(rewrites.get(formattedNumber), formattedNumber);
|
||||
RecipientId recipientId = Recipient.externalContact(context, realNumber).getId();
|
||||
String displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
|
||||
String contactPhotoUri = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.PHOTO_URI));
|
||||
String contactLabel = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LABEL));
|
||||
int phoneType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.TYPE));
|
||||
Uri contactUri = ContactsContract.Contacts.getLookupUri(cursor.getLong(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone._ID)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY)));
|
||||
|
||||
handle.setSystemContactInfo(recipientId, displayName, contactPhotoUri, contactLabel, phoneType, contactUri.toString());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
handle.finish();
|
||||
}
|
||||
|
||||
if (NotificationChannels.supported()) {
|
||||
try (RecipientDatabase.RecipientReader recipients = DatabaseFactory.getRecipientDatabase(context).getRecipientsWithNotificationChannels()) {
|
||||
Recipient recipient;
|
||||
while ((recipient = recipients.getNext()) != null) {
|
||||
NotificationChannels.updateContactChannelName(context, recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (RemoteException | OperationApplicationException e) {
|
||||
Log.w(TAG, "Failed to update contacts.", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void notifyNewUsers(@NonNull Context context,
|
||||
@NonNull List<RecipientId> newUsers)
|
||||
{
|
||||
if (!TextSecurePreferences.isNewContactsNotificationEnabled(context)) return;
|
||||
|
||||
for (RecipientId newUser: newUsers) {
|
||||
Recipient recipient = Recipient.resolved(newUser);
|
||||
if (!SessionUtil.hasSession(context, recipient.getId()) && !recipient.isLocalNumber()) {
|
||||
IncomingJoinedMessage message = new IncomingJoinedMessage(newUser);
|
||||
Optional<InsertResult> insertResult = DatabaseFactory.getSmsDatabase(context).insertMessageInbox(message);
|
||||
|
||||
if (insertResult.isPresent()) {
|
||||
int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
|
||||
if (hour >= 9 && hour < 23) {
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), true);
|
||||
} else {
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<AccountHolder> getOrCreateAccount(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account[] accounts = accountManager.getAccountsByType("org.thoughtcrime.securesms");
|
||||
|
||||
Optional<AccountHolder> account;
|
||||
|
||||
if (accounts.length == 0) account = createAccount(context);
|
||||
else account = Optional.of(new AccountHolder(accounts[0], false));
|
||||
|
||||
if (account.isPresent() && !ContentResolver.getSyncAutomatically(account.get().getAccount(), ContactsContract.AUTHORITY)) {
|
||||
ContentResolver.setSyncAutomatically(account.get().getAccount(), ContactsContract.AUTHORITY, true);
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
private static Optional<AccountHolder> createAccount(Context context) {
|
||||
AccountManager accountManager = AccountManager.get(context);
|
||||
Account account = new Account(context.getString(R.string.app_name), "org.thoughtcrime.securesms");
|
||||
|
||||
if (accountManager.addAccountExplicitly(account, null, null)) {
|
||||
Log.i(TAG, "Created new account...");
|
||||
ContentResolver.setIsSyncable(account, ContactsContract.AUTHORITY, 1);
|
||||
return Optional.of(new AccountHolder(account, true));
|
||||
} else {
|
||||
Log.w(TAG, "Failed to create account!");
|
||||
return Optional.absent();
|
||||
}
|
||||
}
|
||||
|
||||
private static DirectoryResult getDirectoryResult(@NonNull Context context,
|
||||
@NonNull SignalServiceAccountManager accountManager,
|
||||
@NonNull RecipientDatabase recipientDatabase,
|
||||
@NonNull Set<String> locallyStoredNumbers,
|
||||
@NonNull Set<String> eligibleContactNumbers)
|
||||
throws IOException
|
||||
{
|
||||
FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(eligibleContactNumbers, locallyStoredNumbers);
|
||||
List<ContactTokenDetails> activeTokens = accountManager.getContacts(inputResult.getNumbers());
|
||||
Set<String> activeNumbers = Stream.of(activeTokens).map(ContactTokenDetails::getNumber).collect(Collectors.toSet());
|
||||
FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(activeNumbers, inputResult);
|
||||
|
||||
if (inputResult.getFuzzies().size() > 0) {
|
||||
Log.i(TAG, "[getDirectoryResult] Got a fuzzy number result.");
|
||||
}
|
||||
|
||||
if (outputResult.getRewrites().size() > 0) {
|
||||
Log.i(TAG, "[getDirectoryResult] Need to rewrite some numbers.");
|
||||
}
|
||||
|
||||
recipientDatabase.updatePhoneNumbers(outputResult.getRewrites());
|
||||
|
||||
List<RecipientId> activeIds = new LinkedList<>();
|
||||
List<RecipientId> inactiveIds = new LinkedList<>();
|
||||
|
||||
Set<String> inactiveContactNumbers = new HashSet<>(inputResult.getNumbers());
|
||||
inactiveContactNumbers.removeAll(outputResult.getRewrites().keySet());
|
||||
|
||||
for (String number : outputResult.getNumbers()) {
|
||||
activeIds.add(recipientDatabase.getOrInsertFromE164(number));
|
||||
inactiveContactNumbers.remove(number);
|
||||
}
|
||||
|
||||
for (String inactiveContactNumber : inactiveContactNumbers) {
|
||||
inactiveIds.add(recipientDatabase.getOrInsertFromE164(inactiveContactNumber));
|
||||
}
|
||||
|
||||
Set<RecipientId> currentActiveIds = new HashSet<>(recipientDatabase.getRegistered());
|
||||
Set<RecipientId> contactIds = new HashSet<>(recipientDatabase.getSystemContacts());
|
||||
List<RecipientId> newlyActiveIds = Stream.of(activeIds)
|
||||
.filter(id -> !currentActiveIds.contains(id))
|
||||
.filter(contactIds::contains)
|
||||
.toList();
|
||||
|
||||
recipientDatabase.setRegistered(activeIds, inactiveIds);
|
||||
updateContactsDatabase(context, activeIds, true, outputResult.getRewrites());
|
||||
|
||||
Set<String> activeContactNumbers = Stream.of(activeIds).map(Recipient::resolved).filter(Recipient::hasSmsAddress).map(Recipient::requireSmsAddress).collect(Collectors.toSet());
|
||||
|
||||
if (TextSecurePreferences.hasSuccessfullyRetrievedDirectory(context)) {
|
||||
return new DirectoryResult(activeContactNumbers, newlyActiveIds);
|
||||
} else {
|
||||
TextSecurePreferences.setHasSuccessfullyRetrievedDirectory(context, true);
|
||||
return new DirectoryResult(activeContactNumbers);
|
||||
}
|
||||
}
|
||||
|
||||
private static RegisteredState getRegisteredState(@NonNull Context context,
|
||||
@NonNull SignalServiceAccountManager accountManager,
|
||||
@NonNull RecipientDatabase recipientDatabase,
|
||||
@NonNull Recipient recipient)
|
||||
throws IOException
|
||||
{
|
||||
boolean activeUser = recipient.resolve().getRegistered() == RegisteredState.REGISTERED;
|
||||
boolean systemContact = recipient.isSystemContact();
|
||||
Optional<ContactTokenDetails> details = Optional.absent();
|
||||
Map<String, String> rewrites = new HashMap<>();
|
||||
|
||||
if (recipient.hasE164()) {
|
||||
FuzzyPhoneNumberHelper.InputResult inputResult = FuzzyPhoneNumberHelper.generateInput(Collections.singletonList(recipient.requireE164()), recipientDatabase.getAllPhoneNumbers());
|
||||
|
||||
if (inputResult.getNumbers().size() > 1) {
|
||||
Log.i(TAG, "[getRegisteredState] Got a fuzzy number result.");
|
||||
|
||||
List<ContactTokenDetails> detailList = accountManager.getContacts(inputResult.getNumbers());
|
||||
Collection<String> registered = Stream.of(detailList).map(ContactTokenDetails::getNumber).collect(Collectors.toSet());
|
||||
FuzzyPhoneNumberHelper.OutputResult outputResult = FuzzyPhoneNumberHelper.generateOutput(registered, inputResult);
|
||||
String finalNumber = recipient.requireE164();
|
||||
ContactTokenDetails detail = new ContactTokenDetails();
|
||||
|
||||
if (outputResult.getRewrites().size() > 0 && outputResult.getRewrites().containsKey(finalNumber)) {
|
||||
Log.i(TAG, "[getRegisteredState] Need to rewrite a number.");
|
||||
finalNumber = outputResult.getRewrites().get(finalNumber);
|
||||
rewrites = outputResult.getRewrites();
|
||||
}
|
||||
|
||||
detail.setNumber(finalNumber);
|
||||
details = Optional.of(detail);
|
||||
|
||||
recipientDatabase.updatePhoneNumbers(outputResult.getRewrites());
|
||||
} else {
|
||||
details = accountManager.getContact(recipient.requireE164());
|
||||
}
|
||||
}
|
||||
|
||||
if (details.isPresent()) {
|
||||
recipientDatabase.setRegistered(recipient.getId(), RegisteredState.REGISTERED);
|
||||
|
||||
if (Permissions.hasAll(context, Manifest.permission.WRITE_CONTACTS)) {
|
||||
updateContactsDatabase(context, Util.asList(recipient.getId()), false, rewrites);
|
||||
}
|
||||
|
||||
if (!activeUser && TextSecurePreferences.isMultiDevice(context)) {
|
||||
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob());
|
||||
}
|
||||
|
||||
if (!activeUser && systemContact && !TextSecurePreferences.getNeedsSqlCipherMigration(context)) {
|
||||
notifyNewUsers(context, Collections.singletonList(recipient.getId()));
|
||||
}
|
||||
|
||||
return RegisteredState.REGISTERED;
|
||||
} else {
|
||||
recipientDatabase.setRegistered(recipient.getId(), RegisteredState.NOT_REGISTERED);
|
||||
return RegisteredState.NOT_REGISTERED;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isValidContactNumber(@Nullable String number) {
|
||||
return !TextUtils.isEmpty(number) && !UuidUtil.isUuid(number);
|
||||
}
|
||||
|
||||
private static boolean isUuidRegistered(@NonNull Context context, @NonNull Recipient recipient) throws IOException {
|
||||
try {
|
||||
ProfileUtil.retrieveProfile(context, recipient, SignalServiceProfile.RequestType.PROFILE).get(10, TimeUnit.SECONDS);
|
||||
return true;
|
||||
} catch (ExecutionException e) {
|
||||
if (e.getCause() instanceof NotFoundException) {
|
||||
return false;
|
||||
} else {
|
||||
throw new IOException(e);
|
||||
}
|
||||
} catch (InterruptedException | TimeoutException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static class DirectoryResult {
|
||||
|
||||
private final Set<String> numbers;
|
||||
private final List<RecipientId> newlyActiveRecipients;
|
||||
|
||||
DirectoryResult(@NonNull Set<String> numbers) {
|
||||
this(numbers, Collections.emptyList());
|
||||
}
|
||||
|
||||
DirectoryResult(@NonNull Set<String> numbers, @NonNull List<RecipientId> newlyActiveRecipients) {
|
||||
this.numbers = numbers;
|
||||
this.newlyActiveRecipients = newlyActiveRecipients;
|
||||
}
|
||||
|
||||
Set<String> getNumbers() {
|
||||
return numbers;
|
||||
}
|
||||
|
||||
List<RecipientId> getNewlyActiveRecipients() {
|
||||
return newlyActiveRecipients;
|
||||
}
|
||||
}
|
||||
|
||||
private static class AccountHolder {
|
||||
|
||||
private final boolean fresh;
|
||||
private final Account account;
|
||||
|
||||
private AccountHolder(Account account, boolean fresh) {
|
||||
this.fresh = fresh;
|
||||
this.account = account;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public boolean isFresh() {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
public Account getAccount() {
|
||||
return account;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A helper class to match a single number with multiple possible registered numbers. An example is
|
||||
@@ -67,6 +68,32 @@ class FuzzyPhoneNumberHelper {
|
||||
return new OutputResult(allNumbers, rewrites);
|
||||
}
|
||||
|
||||
/**
|
||||
* This should be run on the list of numbers we find out are registered with the server. Based on
|
||||
* these results and our initial input set, we can decide if we need to rewrite which number we
|
||||
* have stored locally.
|
||||
*/
|
||||
static @NonNull OutputResultV2 generateOutputV2(@NonNull Map<String, UUID> registeredNumbers, @NonNull InputResult inputResult) {
|
||||
Map<String, UUID> allNumbers = new HashMap<>(registeredNumbers);
|
||||
Map<String, String> rewrites = new HashMap<>();
|
||||
|
||||
for (Map.Entry<String, String> entry : inputResult.getFuzzies().entrySet()) {
|
||||
if (registeredNumbers.containsKey(entry.getKey()) && registeredNumbers.containsKey(entry.getValue())) {
|
||||
if (mxHas1(entry.getKey())) {
|
||||
rewrites.put(entry.getKey(), entry.getValue());
|
||||
allNumbers.remove(entry.getKey());
|
||||
} else {
|
||||
allNumbers.remove(entry.getValue());
|
||||
}
|
||||
} else if (registeredNumbers.containsKey(entry.getValue())) {
|
||||
rewrites.put(entry.getKey(), entry.getValue());
|
||||
allNumbers.remove(entry.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
return new OutputResultV2(allNumbers, rewrites);
|
||||
}
|
||||
|
||||
|
||||
private static boolean mx(@NonNull String number) {
|
||||
return number.startsWith("+52") && (number.length() == 13 || number.length() == 14);
|
||||
@@ -127,4 +154,22 @@ class FuzzyPhoneNumberHelper {
|
||||
return rewrites;
|
||||
}
|
||||
}
|
||||
|
||||
public static class OutputResultV2 {
|
||||
private final Map<String, UUID> numbers;
|
||||
private final Map<String, String> rewrites;
|
||||
|
||||
private OutputResultV2(@NonNull Map<String, UUID> numbers, @NonNull Map<String, String> rewrites) {
|
||||
this.numbers = numbers;
|
||||
this.rewrites = rewrites;
|
||||
}
|
||||
|
||||
public @NonNull Map<String, UUID> getNumbers() {
|
||||
return numbers;
|
||||
}
|
||||
|
||||
public @NonNull Map<String, String> getRewrites() {
|
||||
return rewrites;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ public class Contact implements Parcelable {
|
||||
|
||||
private static Attachment attachmentFromUri(@Nullable Uri uri) {
|
||||
if (uri == null) return null;
|
||||
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, false, false, null, null, null, null, null);
|
||||
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, false, false, false, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -41,7 +41,8 @@ import android.provider.Browser;
|
||||
import android.provider.ContactsContract;
|
||||
import android.provider.Telephony;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.Gravity;
|
||||
import android.view.KeyEvent;
|
||||
@@ -63,6 +64,7 @@ import android.widget.Toast;
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SearchView;
|
||||
@@ -72,7 +74,9 @@ import androidx.core.content.pm.ShortcutManagerCompat;
|
||||
import androidx.core.graphics.drawable.IconCompat;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.request.target.CustomTarget;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
@@ -110,6 +114,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiStrings;
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
|
||||
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
|
||||
import org.thoughtcrime.securesms.components.location.SignalPlace;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.Reminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.ReminderView;
|
||||
@@ -123,7 +128,9 @@ import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity;
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.crypto.SecurityEvent;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -134,12 +141,14 @@ import org.thoughtcrime.securesms.database.GroupDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase.MarkedMessageInfo;
|
||||
import org.thoughtcrime.securesms.database.MentionUtil;
|
||||
import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns.Types;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.identity.IdentityRecordList;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
@@ -147,18 +156,18 @@ import org.thoughtcrime.securesms.database.model.StickerRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
|
||||
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
|
||||
import org.thoughtcrime.securesms.groups.GroupInsufficientRightsException;
|
||||
import org.thoughtcrime.securesms.groups.GroupChangeException;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupManager;
|
||||
import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupChangeResult;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupErrors;
|
||||
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
|
||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.invites.InviteReminderModel;
|
||||
import org.thoughtcrime.securesms.invites.InviteReminderRepository;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
|
||||
@@ -178,6 +187,7 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestsBottomView;
|
||||
import org.thoughtcrime.securesms.mms.AttachmentManager;
|
||||
import org.thoughtcrime.securesms.mms.AttachmentManager.MediaType;
|
||||
import org.thoughtcrime.securesms.mms.AudioSlide;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
|
||||
import org.thoughtcrime.securesms.mms.GifSlide;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
@@ -193,11 +203,11 @@ import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.mms.StickerSlide;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.GroupShareProfileView;
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -217,6 +227,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.stickers.StickerManagementActivity;
|
||||
import org.thoughtcrime.securesms.stickers.StickerPackInstallEvent;
|
||||
import org.thoughtcrime.securesms.stickers.StickerSearchRepository;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
@@ -224,6 +235,7 @@ import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageUtil;
|
||||
@@ -243,10 +255,15 @@ import org.whispersystems.libsignal.util.Pair;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@@ -274,7 +291,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
AttachmentKeyboard.Callback,
|
||||
ConversationReactionOverlay.OnReactionSelectedListener,
|
||||
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
|
||||
SafetyNumberChangeDialog.Callback
|
||||
SafetyNumberChangeDialog.Callback,
|
||||
ReactionsBottomSheetDialogFragment.Callback
|
||||
{
|
||||
|
||||
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
|
||||
@@ -288,6 +306,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
public static final String TEXT_EXTRA = "draft_text";
|
||||
public static final String MEDIA_EXTRA = "media_list";
|
||||
public static final String STICKER_EXTRA = "sticker_extra";
|
||||
public static final String BORDERLESS_EXTRA = "borderless_extra";
|
||||
public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type";
|
||||
public static final String STARTING_POSITION_EXTRA = "starting_position";
|
||||
|
||||
@@ -334,6 +353,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
protected HidingLinearLayout inlineAttachmentToggle;
|
||||
private InputPanel inputPanel;
|
||||
private View panelParent;
|
||||
private View noLongerMemberBanner;
|
||||
private Stub<View> mentionsSuggestions;
|
||||
|
||||
private LinkPreviewViewModel linkPreviewViewModel;
|
||||
private ConversationSearchViewModel searchViewModel;
|
||||
@@ -350,7 +371,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private boolean isMmsEnabled = true;
|
||||
private boolean isSecurityInitialized = false;
|
||||
|
||||
private final IdentityRecordList identityRecords = new IdentityRecordList();
|
||||
private IdentityRecordList identityRecords = new IdentityRecordList(Collections.emptyList());
|
||||
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
|
||||
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
|
||||
|
||||
@@ -407,6 +428,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
initializeStickerObserver();
|
||||
initializeViewModel();
|
||||
initializeGroupViewModel();
|
||||
if (FeatureFlags.mentions()) initializeMentionsViewModel();
|
||||
initializeEnabledCheck();
|
||||
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
|
||||
@Override
|
||||
@@ -499,11 +521,15 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
calculateCharactersRemaining();
|
||||
|
||||
if (recipientSnapshot.getGroupId().isPresent() && recipientSnapshot.getGroupId().get().isV2()) {
|
||||
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(recipientSnapshot.getGroupId().get().requireV2()));
|
||||
GroupId.V2 groupId = recipientSnapshot.getGroupId().get().requireV2();
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(new RequestGroupV2InfoJob(groupId))
|
||||
.then(new GroupV2UpdateSelfProfileKeyJob(groupId))
|
||||
.enqueue();
|
||||
}
|
||||
|
||||
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
|
||||
markThreadAsRead();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -611,7 +637,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
setMedia(data.getData(),
|
||||
MediaType.GIF,
|
||||
data.getIntExtra(GiphyActivity.EXTRA_WIDTH, 0),
|
||||
data.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0));
|
||||
data.getIntExtra(GiphyActivity.EXTRA_HEIGHT, 0),
|
||||
data.getBooleanExtra(GiphyActivity.EXTRA_BORDERLESS, false));
|
||||
break;
|
||||
case SMS_DEFAULT:
|
||||
initializeSecurity(isSecureText, isDefaultSms);
|
||||
@@ -630,14 +657,15 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
boolean initiating = threadId == -1;
|
||||
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
List<Mention> mentions = new ArrayList<>(result.getMentions());
|
||||
|
||||
for (Media mediaItem : result.getNonUploadedMedia()) {
|
||||
if (MediaUtil.isVideoType(mediaItem.getMimeType())) {
|
||||
slideDeck.addSlide(new VideoSlide(this, mediaItem.getUri(), 0, mediaItem.getCaption().orNull(), mediaItem.getTransformProperties().orNull()));
|
||||
} else if (MediaUtil.isGif(mediaItem.getMimeType())) {
|
||||
slideDeck.addSlide(new GifSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull()));
|
||||
slideDeck.addSlide(new GifSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.isBorderless(), mediaItem.getCaption().orNull()));
|
||||
} else if (MediaUtil.isImageType(mediaItem.getMimeType())) {
|
||||
slideDeck.addSlide(new ImageSlide(this, mediaItem.getUri(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.getCaption().orNull(), null));
|
||||
slideDeck.addSlide(new ImageSlide(this, mediaItem.getUri(), mediaItem.getMimeType(), 0, mediaItem.getWidth(), mediaItem.getHeight(), mediaItem.isBorderless(), mediaItem.getCaption().orNull(), null));
|
||||
} else {
|
||||
Log.w(TAG, "Asked to send an unexpected mimeType: '" + mediaItem.getMimeType() + "'. Skipping.");
|
||||
}
|
||||
@@ -651,6 +679,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
quote,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
mentions,
|
||||
expiresIn,
|
||||
result.isViewOnce(),
|
||||
subscriptionId,
|
||||
@@ -995,26 +1024,20 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
if (activeGroup) {
|
||||
try {
|
||||
GroupManager.updateGroupTimer(ConversationActivity.this, getRecipient().requireGroupId().requirePush(), expirationTime);
|
||||
} catch (GroupInsufficientRightsException e) {
|
||||
} catch (GroupChangeException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return ConversationActivity.this.getString(R.string.ManageGroupActivity_you_dont_have_the_rights_to_do_this);
|
||||
} catch (GroupNotAMemberException e) {
|
||||
Log.w(TAG, e);
|
||||
return ConversationActivity.this.getString(R.string.ManageGroupActivity_youre_not_a_member_of_the_group);
|
||||
} catch (GroupChangeFailedException | GroupChangeBusyException | IOException e) {
|
||||
Log.w(TAG, e);
|
||||
return ConversationActivity.this.getString(R.string.ManageGroupActivity_failed_to_update_the_group);
|
||||
return GroupChangeResult.failure(GroupChangeFailureReason.fromException(e));
|
||||
}
|
||||
} else {
|
||||
DatabaseFactory.getRecipientDatabase(ConversationActivity.this).setExpireMessages(recipient.getId(), expirationTime);
|
||||
OutgoingExpirationUpdateMessage outgoingMessage = new OutgoingExpirationUpdateMessage(getRecipient(), System.currentTimeMillis(), expirationTime * 1000L);
|
||||
MessageSender.send(ConversationActivity.this, outgoingMessage, threadId, false, null);
|
||||
}
|
||||
return null;
|
||||
return GroupChangeResult.SUCCESS;
|
||||
},
|
||||
(errorString) -> {
|
||||
if (errorString != null) {
|
||||
Toast.makeText(ConversationActivity.this, errorString, Toast.LENGTH_SHORT).show();
|
||||
(changeResult) -> {
|
||||
if (!changeResult.isSuccess()) {
|
||||
Toast.makeText(ConversationActivity.this, GroupErrors.getUserDisplayMessage(changeResult.getFailureReason()), Toast.LENGTH_SHORT).show();
|
||||
} else {
|
||||
invalidateOptionsMenu();
|
||||
if (fragment != null) fragment.setLastSeen(0);
|
||||
@@ -1196,10 +1219,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
return;
|
||||
}
|
||||
|
||||
LeaveGroupDialog.handleLeavePushGroup(ConversationActivity.this,
|
||||
getLifecycle(),
|
||||
getRecipient().requireGroupId().requirePush(),
|
||||
null);
|
||||
LeaveGroupDialog.handleLeavePushGroup(this, getRecipient().requireGroupId().requirePush(), this::finish);
|
||||
}
|
||||
|
||||
private void handleManageGroup() {
|
||||
@@ -1364,11 +1384,13 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private ListenableFuture<Boolean> initializeDraft() {
|
||||
final SettableFuture<Boolean> result = new SettableFuture<>();
|
||||
|
||||
final String draftText = getIntent().getStringExtra(TEXT_EXTRA);
|
||||
final Uri draftMedia = getIntent().getData();
|
||||
final MediaType draftMediaType = MediaType.from(getIntent().getType());
|
||||
final List<Media> mediaList = getIntent().getParcelableArrayListExtra(MEDIA_EXTRA);
|
||||
final StickerLocator stickerLocator = getIntent().getParcelableExtra(STICKER_EXTRA);
|
||||
final CharSequence draftText = getIntent().getCharSequenceExtra(TEXT_EXTRA);
|
||||
final Uri draftMedia = getIntent().getData();
|
||||
final String draftContentType = getIntent().getType();
|
||||
final MediaType draftMediaType = MediaType.from(draftContentType);
|
||||
final List<Media> mediaList = getIntent().getParcelableArrayListExtra(MEDIA_EXTRA);
|
||||
final StickerLocator stickerLocator = getIntent().getParcelableExtra(STICKER_EXTRA);
|
||||
final boolean borderless = getIntent().getBooleanExtra(BORDERLESS_EXTRA, false);
|
||||
|
||||
if (stickerLocator != null && draftMedia != null) {
|
||||
Log.d(TAG, "Handling shared sticker.");
|
||||
@@ -1376,6 +1398,13 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
return new SettableFuture<>(false);
|
||||
}
|
||||
|
||||
if (draftMedia != null && draftContentType != null && borderless) {
|
||||
SimpleTask.run(getLifecycle(),
|
||||
() -> getKeyboardImageDetails(draftMedia),
|
||||
details -> sendKeyboardImage(draftMedia, draftContentType, details));
|
||||
return new SettableFuture<>(false);
|
||||
}
|
||||
|
||||
if (!Util.isEmpty(mediaList)) {
|
||||
Log.d(TAG, "Handling shared Media.");
|
||||
Intent sendIntent = MediaSendActivity.buildEditorIntent(this, mediaList, recipient.get(), draftText, sendButton.getSelectedTransport());
|
||||
@@ -1406,7 +1435,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
private void initializeEnabledCheck() {
|
||||
groupViewModel.getGroupActiveState().observe(this, state -> {
|
||||
boolean enabled = state == null || !(isPushGroupConversation() && !state.isActiveGroup());
|
||||
boolean inactivePushGroup = state != null && isPushGroupConversation() && !state.isActiveGroup();
|
||||
boolean enabled = !inactivePushGroup;
|
||||
noLongerMemberBanner.setVisibility(enabled ? View.GONE : View.VISIBLE);
|
||||
inputPanel.setVisibility(enabled ? View.VISIBLE : View.GONE);
|
||||
inputPanel.setEnabled(enabled);
|
||||
sendButton.setEnabled(enabled);
|
||||
attachButton.setEnabled(enabled);
|
||||
@@ -1416,19 +1448,34 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private ListenableFuture<Boolean> initializeDraftFromDatabase() {
|
||||
SettableFuture<Boolean> future = new SettableFuture<>();
|
||||
|
||||
new AsyncTask<Void, Void, List<Draft>>() {
|
||||
new AsyncTask<Void, Void, Pair<Drafts, CharSequence>>() {
|
||||
@Override
|
||||
protected List<Draft> doInBackground(Void... params) {
|
||||
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(ConversationActivity.this);
|
||||
List<Draft> results = draftDatabase.getDrafts(threadId);
|
||||
protected Pair<Drafts, CharSequence> doInBackground(Void... params) {
|
||||
Context context = ConversationActivity.this;
|
||||
DraftDatabase draftDatabase = DatabaseFactory.getDraftDatabase(context);
|
||||
Drafts results = draftDatabase.getDrafts(threadId);
|
||||
Draft mentionsDraft = results.getDraftOfType(Draft.MENTION);
|
||||
Spannable updatedText = null;
|
||||
|
||||
if (mentionsDraft != null) {
|
||||
String text = results.getDraftOfType(Draft.TEXT).getValue();
|
||||
List<Mention> mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.getValue()));
|
||||
UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions);
|
||||
|
||||
updatedText = new SpannableString(updated.getBody());
|
||||
MentionAnnotation.setMentionAnnotations(updatedText, updated.getMentions());
|
||||
}
|
||||
|
||||
draftDatabase.clearDrafts(threadId);
|
||||
|
||||
return results;
|
||||
return new Pair<>(results, updatedText);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(List<Draft> drafts) {
|
||||
protected void onPostExecute(Pair<Drafts, CharSequence> draftsWithUpdatedMentions) {
|
||||
Drafts drafts = Objects.requireNonNull(draftsWithUpdatedMentions.first());
|
||||
CharSequence updatedText = draftsWithUpdatedMentions.second();
|
||||
|
||||
if (drafts.isEmpty()) {
|
||||
future.set(false);
|
||||
updateToggleButtonState();
|
||||
@@ -1452,7 +1499,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
try {
|
||||
switch (draft.getType()) {
|
||||
case Draft.TEXT:
|
||||
composeText.setText(draft.getValue());
|
||||
composeText.setText(updatedText == null ? draft.getValue() : updatedText);
|
||||
listener.onSuccess(true);
|
||||
break;
|
||||
case Draft.LOCATION:
|
||||
@@ -1639,7 +1686,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
@Override
|
||||
protected void onPostExecute(@NonNull Pair<IdentityRecordList, String> result) {
|
||||
Log.i(TAG, "Got identity records: " + result.first().isUnverified());
|
||||
identityRecords.replaceWith(result.first());
|
||||
identityRecords = result.first();
|
||||
|
||||
if (result.second() != null) {
|
||||
Log.d(TAG, "Replacing banner...");
|
||||
@@ -1684,10 +1731,13 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
|
||||
messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar);
|
||||
reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber);
|
||||
mentionsSuggestions = ViewUtil.findStubById(this, R.id.conversation_mention_suggestions_stub);
|
||||
|
||||
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
|
||||
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
|
||||
|
||||
noLongerMemberBanner = findViewById(R.id.conversation_no_longer_member_banner);
|
||||
|
||||
container.addOnKeyboardShownListener(this);
|
||||
inputPanel.setListener(this);
|
||||
inputPanel.setMediaListener(this);
|
||||
@@ -1770,7 +1820,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private void initializeLinkPreviewObserver() {
|
||||
linkPreviewViewModel = ViewModelProviders.of(this, new LinkPreviewViewModel.Factory(new LinkPreviewRepository())).get(LinkPreviewViewModel.class);
|
||||
|
||||
if (!TextSecurePreferences.isLinkPreviewsEnabled(this)) {
|
||||
if (!SignalStore.settings().isLinkPreviewsEnabled()) {
|
||||
linkPreviewViewModel.onUserCancel();
|
||||
return;
|
||||
}
|
||||
@@ -1781,6 +1831,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
if (previewState.isLoading()) {
|
||||
Log.d(TAG, "Loading link preview.");
|
||||
inputPanel.setLinkPreviewLoading();
|
||||
} else if (previewState.hasLinks() && !previewState.getLinkPreview().isPresent()) {
|
||||
Log.d(TAG, "No preview found.");
|
||||
inputPanel.setLinkPreviewNoPreview();
|
||||
} else {
|
||||
Log.d(TAG, "Setting link preview: " + previewState.getLinkPreview().isPresent());
|
||||
inputPanel.setLinkPreview(glideRequests, previewState.getLinkPreview());
|
||||
@@ -1846,6 +1899,48 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
|
||||
}
|
||||
|
||||
private void initializeMentionsViewModel() {
|
||||
MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class);
|
||||
|
||||
recipient.observe(this, r -> {
|
||||
if (r.isPushV2Group() && !mentionsSuggestions.resolved()) {
|
||||
mentionsSuggestions.get();
|
||||
}
|
||||
mentionsViewModel.onRecipientChange(r);
|
||||
});
|
||||
|
||||
composeText.setMentionQueryChangedListener(query -> {
|
||||
if (getRecipient().isPushV2Group()) {
|
||||
if (!mentionsSuggestions.resolved()) {
|
||||
mentionsSuggestions.get();
|
||||
}
|
||||
mentionsViewModel.onQueryChange(query);
|
||||
}
|
||||
});
|
||||
|
||||
composeText.setMentionValidator(annotations -> {
|
||||
if (!getRecipient().isPushV2Group()) {
|
||||
return annotations;
|
||||
}
|
||||
|
||||
Set<String> validRecipientIds = Stream.of(getRecipient().getParticipants())
|
||||
.map(r -> MentionAnnotation.idToMentionAnnotationValue(r.getId()))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
return Stream.of(annotations)
|
||||
.filterNot(a -> validRecipientIds.contains(a.getValue()))
|
||||
.toList();
|
||||
});
|
||||
|
||||
mentionsViewModel.getSelectedRecipient().observe(this, recipient -> {
|
||||
String replacementDisplayName = recipient.getDisplayName(this);
|
||||
if (replacementDisplayName.equals(recipient.getDisplayUsername())) {
|
||||
replacementDisplayName = recipient.getUsername().or(replacementDisplayName);
|
||||
}
|
||||
composeText.replaceTextWithMention(replacementDisplayName, recipient.getId());
|
||||
});
|
||||
}
|
||||
|
||||
private void showStickerIntroductionTooltip() {
|
||||
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
|
||||
inputPanel.setMediaKeyboardToggleMode(true);
|
||||
@@ -1984,10 +2079,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
//////// Helper Methods
|
||||
|
||||
private ListenableFuture<Boolean> setMedia(@Nullable Uri uri, @NonNull MediaType mediaType) {
|
||||
return setMedia(uri, mediaType, 0, 0);
|
||||
return setMedia(uri, mediaType, 0, 0, false);
|
||||
}
|
||||
|
||||
private ListenableFuture<Boolean> setMedia(@Nullable Uri uri, @NonNull MediaType mediaType, int width, int height) {
|
||||
private ListenableFuture<Boolean> setMedia(@Nullable Uri uri, @NonNull MediaType mediaType, int width, int height, boolean borderless) {
|
||||
if (uri == null) {
|
||||
return new SettableFuture<>(false);
|
||||
}
|
||||
@@ -1996,7 +2091,12 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
openContactShareEditor(uri);
|
||||
return new SettableFuture<>(false);
|
||||
} else if (MediaType.IMAGE.equals(mediaType) || MediaType.GIF.equals(mediaType) || MediaType.VIDEO.equals(mediaType)) {
|
||||
Media media = new Media(uri, MediaUtil.getMimeType(this, uri), 0, width, height, 0, 0, Optional.absent(), Optional.absent(), Optional.absent());
|
||||
String mimeType = MediaUtil.getMimeType(this, uri);
|
||||
if (mimeType == null) {
|
||||
mimeType = mediaType.toFallbackMimeType();
|
||||
}
|
||||
|
||||
Media media = new Media(uri, mimeType, 0, width, height, 0, 0, borderless, Optional.absent(), Optional.absent(), Optional.absent());
|
||||
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
|
||||
return new SettableFuture<>(false);
|
||||
} else {
|
||||
@@ -2022,7 +2122,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
long expiresIn = recipient.get().getExpireMessages() * 1000L;
|
||||
boolean initiating = threadId == -1;
|
||||
|
||||
sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), expiresIn, false, subscriptionId, initiating, false);
|
||||
sendMediaMessage(isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, false);
|
||||
}
|
||||
|
||||
private void selectContactInfo(ContactData contactData) {
|
||||
@@ -2046,7 +2146,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
Drafts drafts = new Drafts();
|
||||
|
||||
if (!Util.isEmpty(composeText)) {
|
||||
drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed()));
|
||||
drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed().toString()));
|
||||
List<Mention> draftMentions = composeText.getMentions();
|
||||
if (!draftMentions.isEmpty()) {
|
||||
drafts.add(new Draft(Draft.MENTION, Base64.encodeBytes(MentionUtil.mentionsToBodyRangeList(draftMentions).toByteArray())));
|
||||
}
|
||||
}
|
||||
|
||||
for (Slide slide : attachmentManager.buildSlideDeck().getSlides()) {
|
||||
@@ -2127,7 +2231,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
makeDefaultSmsButton.setVisibility(View.VISIBLE);
|
||||
registerButton.setVisibility(View.GONE);
|
||||
} else {
|
||||
inputPanel.setVisibility(View.VISIBLE);
|
||||
boolean inactivePushGroup = isPushGroupConversation() && !recipient.isActiveGroup();
|
||||
inputPanel.setVisibility(inactivePushGroup ? View.GONE : View.VISIBLE);
|
||||
unblockButton.setVisibility(View.GONE);
|
||||
makeDefaultSmsButton.setVisibility(View.GONE);
|
||||
registerButton.setVisibility(View.GONE);
|
||||
@@ -2135,7 +2240,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
private void calculateCharactersRemaining() {
|
||||
String messageBody = composeText.getTextTrimmed();
|
||||
String messageBody = composeText.getTextTrimmed().toString();
|
||||
TransportOption transportOption = sendButton.getSelectedTransport();
|
||||
CharacterState characterState = transportOption.calculateCharacters(messageBody);
|
||||
|
||||
@@ -2218,7 +2323,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
private String getMessage() throws InvalidMessageException {
|
||||
String rawText = composeText.getTextTrimmed();
|
||||
String rawText = composeText.getTextTrimmed().toString();
|
||||
|
||||
if (rawText.length() < 1 && !attachmentManager.isAttachmentPresent())
|
||||
throw new InvalidMessageException(getString(R.string.ConversationActivity_message_is_empty_exclamation));
|
||||
@@ -2232,21 +2337,6 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
: MediaConstraints.getMmsMediaConstraints(sendButton.getSelectedTransport().getSimSubscriptionId().or(-1));
|
||||
}
|
||||
|
||||
private void markThreadAsRead() {
|
||||
new AsyncTask<Long, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Long... params) {
|
||||
Context context = ConversationActivity.this;
|
||||
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(params[0], false);
|
||||
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context);
|
||||
MarkReadReceiver.process(context, messageIds);
|
||||
|
||||
return null;
|
||||
}
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId);
|
||||
}
|
||||
|
||||
private void markLastSeen() {
|
||||
new AsyncTask<Long, Void, Void>() {
|
||||
@Override
|
||||
@@ -2276,6 +2366,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
attachmentManager.cleanup();
|
||||
|
||||
updateLinkPreviewState();
|
||||
linkPreviewViewModel.onSend();
|
||||
}
|
||||
|
||||
private void sendMessage() {
|
||||
@@ -2302,6 +2393,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
recipient.isGroup() ||
|
||||
recipient.getEmail().isPresent() ||
|
||||
inputPanel.getQuote().isPresent() ||
|
||||
composeText.hasMentions() ||
|
||||
linkPreviewViewModel.hasLinkPreview() ||
|
||||
needsSplit;
|
||||
|
||||
@@ -2310,7 +2402,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !isMmsEnabled) {
|
||||
handleManualMmsRequired();
|
||||
} else if (!forceSms && (identityRecords.isUnverified() || identityRecords.isUntrusted())) {
|
||||
} else if (!forceSms && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
|
||||
handleRecentSafetyNumberChange();
|
||||
} else if (isMediaMessage) {
|
||||
sendMediaMessage(forceSms, expiresIn, false, subscriptionId, initiating);
|
||||
@@ -2332,9 +2424,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
private void sendMediaMessage(@NonNull MediaSendActivityResult result) {
|
||||
long expiresIn = recipient.get().getExpireMessages() * 1000L;
|
||||
QuoteModel quote = result.isViewOnce() ? null : inputPanel.getQuote().orNull();
|
||||
List<Mention> mentions = new ArrayList<>(result.getMentions());
|
||||
boolean initiating = threadId == -1;
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList());
|
||||
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message );
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient.get(), new SlideDeck(), result.getBody(), System.currentTimeMillis(), -1, expiresIn, result.isViewOnce(), distributionType, quote, Collections.emptyList(), Collections.emptyList(), mentions);
|
||||
OutgoingMediaMessage secureMessage = new OutgoingSecureMediaMessage(message);
|
||||
|
||||
ApplicationContext.getInstance(this).getTypingStatusSender().onTypingStopped(threadId);
|
||||
|
||||
@@ -2358,15 +2451,27 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
throws InvalidMessageException
|
||||
{
|
||||
Log.i(TAG, "Sending media message...");
|
||||
sendMediaMessage(forceSms, getMessage(), attachmentManager.buildSlideDeck(), inputPanel.getQuote().orNull(), Collections.emptyList(), linkPreviewViewModel.getActiveLinkPreviews(), expiresIn, viewOnce, subscriptionId, initiating, true);
|
||||
sendMediaMessage(forceSms,
|
||||
getMessage(),
|
||||
attachmentManager.buildSlideDeck(),
|
||||
inputPanel.getQuote().orNull(),
|
||||
Collections.emptyList(),
|
||||
linkPreviewViewModel.getActiveLinkPreviews(),
|
||||
composeText.getMentions(),
|
||||
expiresIn,
|
||||
viewOnce,
|
||||
subscriptionId,
|
||||
initiating,
|
||||
true);
|
||||
}
|
||||
|
||||
private ListenableFuture<Void> sendMediaMessage(final boolean forceSms,
|
||||
String body,
|
||||
@NonNull String body,
|
||||
SlideDeck slideDeck,
|
||||
QuoteModel quote,
|
||||
List<Contact> contacts,
|
||||
List<LinkPreview> previews,
|
||||
List<Mention> mentions,
|
||||
final long expiresIn,
|
||||
final boolean viewOnce,
|
||||
final int subscriptionId,
|
||||
@@ -2387,7 +2492,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient.get(), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews);
|
||||
OutgoingMediaMessage outgoingMessageCandidate = new OutgoingMediaMessage(recipient.get(), slideDeck, body, System.currentTimeMillis(), subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions);
|
||||
|
||||
final SettableFuture<Void> future = new SettableFuture<>();
|
||||
final Context context = getApplicationContext();
|
||||
@@ -2495,7 +2600,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
buttonToggle.display(sendButton);
|
||||
quickAttachmentToggle.hide();
|
||||
|
||||
if (!attachmentManager.isAttachmentPresent() && !linkPreviewViewModel.hasLinkPreview()) {
|
||||
if (!attachmentManager.isAttachmentPresent() && !linkPreviewViewModel.hasLinkPreviewUi()) {
|
||||
inlineAttachmentToggle.show();
|
||||
} else {
|
||||
inlineAttachmentToggle.hide();
|
||||
@@ -2504,9 +2609,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
private void updateLinkPreviewState() {
|
||||
if (TextSecurePreferences.isLinkPreviewsEnabled(this) && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) {
|
||||
if (SignalStore.settings().isLinkPreviewsEnabled() && !sendButton.getSelectedTransport().isSms() && !attachmentManager.isAttachmentPresent()) {
|
||||
linkPreviewViewModel.onEnabled();
|
||||
linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), composeText.getSelectionStart(), composeText.getSelectionEnd());
|
||||
linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), composeText.getSelectionStart(), composeText.getSelectionEnd());
|
||||
} else {
|
||||
linkPreviewViewModel.onUserCancel();
|
||||
}
|
||||
@@ -2574,7 +2679,20 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
slideDeck.addSlide(audioSlide);
|
||||
|
||||
sendMediaMessage(forceSms, "", slideDeck, inputPanel.getQuote().orNull(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, true).addListener(new AssertedSuccessListener<Void>() {
|
||||
ListenableFuture<Void> sendResult = sendMediaMessage(forceSms,
|
||||
"",
|
||||
slideDeck,
|
||||
inputPanel.getQuote().orNull(),
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
composeText.getMentions(),
|
||||
expiresIn,
|
||||
false,
|
||||
subscriptionId,
|
||||
initiating,
|
||||
true);
|
||||
|
||||
sendResult.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void nothing) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@@ -2650,10 +2768,10 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
@Override
|
||||
public void onMediaSelected(@NonNull Uri uri, String contentType) {
|
||||
if (!TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif")) {
|
||||
setMedia(uri, MediaType.GIF);
|
||||
} else if (MediaUtil.isImageType(contentType)) {
|
||||
setMedia(uri, MediaType.IMAGE);
|
||||
if (MediaUtil.isGif(contentType) || MediaUtil.isImageType(contentType)) {
|
||||
SimpleTask.run(getLifecycle(),
|
||||
() -> getKeyboardImageDetails(uri),
|
||||
details -> sendKeyboardImage(uri, contentType, details));
|
||||
} else if (MediaUtil.isVideoType(contentType)) {
|
||||
setMedia(uri, MediaType.VIDEO);
|
||||
} else if (MediaUtil.isAudioType(contentType)) {
|
||||
@@ -2663,7 +2781,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
@Override
|
||||
public void onCursorPositionChanged(int start, int end) {
|
||||
linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed(), start, end);
|
||||
linkPreviewViewModel.onTextChanged(this, composeText.getTextTrimmed().toString(), start, end);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2688,7 +2806,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
private void sendSticker(@NonNull StickerLocator stickerLocator, @NonNull Uri uri, long size, boolean clearCompose) {
|
||||
if (sendButton.getSelectedTransport().isSms()) {
|
||||
Media media = new Media(uri, MediaUtil.IMAGE_WEBP, System.currentTimeMillis(), StickerSlide.WIDTH, StickerSlide.HEIGHT, size, 0, Optional.absent(), Optional.absent(), Optional.absent());
|
||||
Media media = new Media(uri, MediaUtil.IMAGE_WEBP, System.currentTimeMillis(), StickerSlide.WIDTH, StickerSlide.HEIGHT, size, 0, false, Optional.absent(), Optional.absent(), Optional.absent());
|
||||
Intent intent = MediaSendActivity.buildEditorIntent(this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
|
||||
startActivityForResult(intent, MEDIA_SENDER);
|
||||
return;
|
||||
@@ -2703,7 +2821,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
slideDeck.addSlide(stickerSlide);
|
||||
|
||||
sendMediaMessage(transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose);
|
||||
sendMediaMessage(transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose);
|
||||
}
|
||||
|
||||
private void silentlySetComposeText(String text) {
|
||||
@@ -2712,6 +2830,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
typingTextWatcher.setEnabled(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReactionsDialogDismissed() {
|
||||
reactionOverlay.hideMask();
|
||||
}
|
||||
|
||||
// Listeners
|
||||
|
||||
private class QuickCameraToggleListener implements OnClickListener {
|
||||
@@ -2826,25 +2949,44 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
@Override
|
||||
public void onMessageRequest(@NonNull MessageRequestViewModel viewModel) {
|
||||
messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.onAccept(this::showGroupChangeErrorToast));
|
||||
messageRequestBottomView.setAcceptOnClickListener(v -> viewModel.onAccept());
|
||||
messageRequestBottomView.setDeleteOnClickListener(v -> onMessageRequestDeleteClicked(viewModel));
|
||||
messageRequestBottomView.setBlockOnClickListener(v -> onMessageRequestBlockClicked(viewModel));
|
||||
messageRequestBottomView.setUnblockOnClickListener(v -> onMessageRequestUnblockClicked(viewModel));
|
||||
|
||||
viewModel.getRecipient().observe(this, this::presentMessageRequestBottomViewTo);
|
||||
viewModel.getMessageRequestDisplayState().observe(this, this::presentMessageRequestDisplayState);
|
||||
viewModel.getFailures().observe(this, this::showGroupChangeErrorToast);
|
||||
viewModel.getMessageRequestStatus().observe(this, status -> {
|
||||
switch (status) {
|
||||
case IDLE:
|
||||
hideMessageRequestBusy();
|
||||
break;
|
||||
case ACCEPTING:
|
||||
case BLOCKING:
|
||||
case DELETING:
|
||||
showMessageRequestBusy();
|
||||
break;
|
||||
case ACCEPTED:
|
||||
hideMessageRequestBusy();
|
||||
messageRequestBottomView.setVisibility(View.GONE);
|
||||
return;
|
||||
break;
|
||||
case DELETED:
|
||||
case BLOCKED:
|
||||
hideMessageRequestBusy();
|
||||
finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showMessageRequestBusy() {
|
||||
messageRequestBottomView.showBusy();
|
||||
}
|
||||
|
||||
private void hideMessageRequestBusy() {
|
||||
messageRequestBottomView.hideBusy();
|
||||
}
|
||||
|
||||
private void showGroupChangeErrorToast(@NonNull GroupChangeFailureReason e) {
|
||||
Toast.makeText(this, GroupErrors.getUserDisplayMessage(e), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
@@ -2857,7 +2999,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
{
|
||||
reactionOverlay.setOnToolbarItemClickedListener(toolbarListener);
|
||||
reactionOverlay.setOnHideListener(onHideListener);
|
||||
reactionOverlay.show(this, maskTarget, messageRecord, panelParent.getMeasuredHeight());
|
||||
reactionOverlay.show(this, maskTarget, messageRecord, inputAreaHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -2880,6 +3022,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleReactionDetails(@NonNull View maskTarget) {
|
||||
reactionOverlay.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCursorChanged() {
|
||||
if (!reactionOverlay.isShowing()) {
|
||||
@@ -2903,7 +3050,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleReplyMessage(MessageRecord messageRecord) {
|
||||
public void handleReplyMessage(ConversationMessage conversationMessage) {
|
||||
MessageRecord messageRecord = conversationMessage.getMessageRecord();
|
||||
|
||||
Recipient author;
|
||||
|
||||
if (messageRecord.isOutgoing()) {
|
||||
@@ -2939,7 +3088,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
inputPanel.setQuote(GlideApp.with(this),
|
||||
messageRecord.getDateSent(),
|
||||
author,
|
||||
messageRecord.getBody(),
|
||||
conversationMessage.getDisplayBody(this),
|
||||
slideDeck);
|
||||
} else {
|
||||
SlideDeck slideDeck = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getSlideDeck() : new SlideDeck();
|
||||
@@ -2953,7 +3102,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
inputPanel.setQuote(GlideApp.with(this),
|
||||
messageRecord.getDateSent(),
|
||||
author,
|
||||
messageRecord.getBody(),
|
||||
conversationMessage.getDisplayBody(this),
|
||||
slideDeck);
|
||||
}
|
||||
|
||||
@@ -2977,6 +3126,19 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
updateLinkPreviewState();
|
||||
}
|
||||
|
||||
private int inputAreaHeight() {
|
||||
int height = panelParent.getMeasuredHeight();
|
||||
|
||||
if (attachmentKeyboardStub.resolved()) {
|
||||
View keyboard = attachmentKeyboardStub.get();
|
||||
if (keyboard.getVisibility() == View.VISIBLE) {
|
||||
return height + keyboard.getMeasuredHeight();
|
||||
}
|
||||
}
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
private void onMessageRequestDeleteClicked(@NonNull MessageRequestViewModel requestModel) {
|
||||
Recipient recipient = requestModel.getRecipient().getValue();
|
||||
if (recipient == null) {
|
||||
@@ -3065,6 +3227,56 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @Nullable KeyboardImageDetails getKeyboardImageDetails(@NonNull Uri uri) {
|
||||
try {
|
||||
Bitmap bitmap = glideRequests.asBitmap()
|
||||
.load(new DecryptableStreamUriLoader.DecryptableUri(uri))
|
||||
.skipMemoryCache(true)
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.submit()
|
||||
.get(1000, TimeUnit.MILLISECONDS);
|
||||
int topLeft = bitmap.getPixel(0, 0);
|
||||
return new KeyboardImageDetails(bitmap.getWidth(), bitmap.getHeight(), Color.alpha(topLeft) < 255);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void sendKeyboardImage(@NonNull Uri uri, @NonNull String contentType, @Nullable KeyboardImageDetails details) {
|
||||
if (details == null || !details.hasTransparency) {
|
||||
setMedia(uri, Objects.requireNonNull(MediaType.from(contentType)));
|
||||
return;
|
||||
}
|
||||
|
||||
long expiresIn = recipient.get().getExpireMessages() * 1000L;
|
||||
int subscriptionId = sendButton.getSelectedTransport().getSimSubscriptionId().or(-1);
|
||||
boolean initiating = threadId == -1;
|
||||
QuoteModel quote = inputPanel.getQuote().orNull();
|
||||
SlideDeck slideDeck = new SlideDeck();
|
||||
|
||||
if (MediaUtil.isGif(contentType)) {
|
||||
slideDeck.addSlide(new GifSlide(this, uri, 0, details.width, details.height, details.hasTransparency, null));
|
||||
} else if (MediaUtil.isImageType(contentType)) {
|
||||
slideDeck.addSlide(new ImageSlide(this, uri, contentType, 0, details.width, details.height, details.hasTransparency, null, null));
|
||||
} else {
|
||||
throw new AssertionError("Only images are supported!");
|
||||
}
|
||||
|
||||
sendMediaMessage(isSmsForced(),
|
||||
"",
|
||||
slideDeck,
|
||||
quote,
|
||||
Collections.emptyList(),
|
||||
Collections.emptyList(),
|
||||
composeText.getMentions(),
|
||||
expiresIn,
|
||||
false,
|
||||
subscriptionId,
|
||||
initiating,
|
||||
true);
|
||||
}
|
||||
|
||||
private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener {
|
||||
@Override
|
||||
public void onDismissed(final List<IdentityRecord> unverifiedIdentities) {
|
||||
@@ -3116,7 +3328,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
}
|
||||
|
||||
private class QuoteRestorationTask extends AsyncTask<Void, Void, MessageRecord> {
|
||||
private class QuoteRestorationTask extends AsyncTask<Void, Void, ConversationMessage> {
|
||||
|
||||
private final String serialized;
|
||||
private final SettableFuture<Boolean> future;
|
||||
@@ -3127,20 +3339,27 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
}
|
||||
|
||||
@Override
|
||||
protected MessageRecord doInBackground(Void... voids) {
|
||||
protected ConversationMessage doInBackground(Void... voids) {
|
||||
QuoteId quoteId = QuoteId.deserialize(ConversationActivity.this, serialized);
|
||||
|
||||
if (quoteId != null) {
|
||||
return DatabaseFactory.getMmsSmsDatabase(getApplicationContext()).getMessageFor(quoteId.getId(), quoteId.getAuthor());
|
||||
if (quoteId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
Context context = getApplicationContext();
|
||||
|
||||
MessageRecord messageRecord = DatabaseFactory.getMmsSmsDatabase(context).getMessageFor(quoteId.getId(), quoteId.getAuthor());
|
||||
if (messageRecord == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(MessageRecord messageRecord) {
|
||||
if (messageRecord != null) {
|
||||
handleReplyMessage(messageRecord);
|
||||
protected void onPostExecute(ConversationMessage conversationMessage) {
|
||||
if (conversationMessage != null) {
|
||||
handleReplyMessage(conversationMessage);
|
||||
future.set(true);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to restore a quote from a draft. No matching message record.");
|
||||
@@ -3154,4 +3373,16 @@ public class ConversationActivity extends PassphraseRequiredActivity
|
||||
|
||||
messageRequestBottomView.setRecipient(recipient);
|
||||
}
|
||||
|
||||
private static class KeyboardImageDetails {
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final boolean hasTransparency;
|
||||
|
||||
private KeyboardImageDetails(int width, int height, boolean hasTransparency) {
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.hasTransparency = hasTransparency;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -65,8 +64,8 @@ import java.util.Set;
|
||||
* manager, so position 0 is at the bottom of the screen. That's why the "header" is at the bottom,
|
||||
* the "footer" is at the top, and we refer to the "next" record as having a lower index.
|
||||
*/
|
||||
public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
extends PagedListAdapter<MessageRecord, RecyclerView.ViewHolder>
|
||||
public class ConversationAdapter
|
||||
extends PagedListAdapter<ConversationMessage, RecyclerView.ViewHolder>
|
||||
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>
|
||||
{
|
||||
|
||||
@@ -89,16 +88,16 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
private final Locale locale;
|
||||
private final Recipient recipient;
|
||||
|
||||
private final Set<MessageRecord> selected;
|
||||
private final List<MessageRecord> fastRecords;
|
||||
private final Set<Long> releasedFastRecords;
|
||||
private final Calendar calendar;
|
||||
private final MessageDigest digest;
|
||||
private final Set<ConversationMessage> selected;
|
||||
private final List<ConversationMessage> fastRecords;
|
||||
private final Set<Long> releasedFastRecords;
|
||||
private final Calendar calendar;
|
||||
private final MessageDigest digest;
|
||||
|
||||
private String searchQuery;
|
||||
private MessageRecord recordToPulseHighlight;
|
||||
private View headerView;
|
||||
private View footerView;
|
||||
private String searchQuery;
|
||||
private ConversationMessage recordToPulse;
|
||||
private View headerView;
|
||||
private View footerView;
|
||||
|
||||
ConversationAdapter(@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@@ -130,7 +129,8 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
return MESSAGE_TYPE_FOOTER;
|
||||
}
|
||||
|
||||
MessageRecord messageRecord = getItem(position);
|
||||
ConversationMessage conversationMessage = getItem(position);
|
||||
MessageRecord messageRecord = (conversationMessage != null) ? conversationMessage.getMessageRecord() : null;
|
||||
|
||||
if (messageRecord == null) {
|
||||
return MESSAGE_TYPE_PLACEHOLDER;
|
||||
@@ -153,16 +153,13 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
return FOOTER_ID;
|
||||
}
|
||||
|
||||
MessageRecord record = getItem(position);
|
||||
ConversationMessage message = getItem(position);
|
||||
|
||||
if (record == null) {
|
||||
if (message == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
String unique = (record.isMms() ? "MMS::" : "SMS::") + record.getId();
|
||||
byte[] bytes = digest.digest(unique.getBytes());
|
||||
|
||||
return Conversions.byteArrayToLong(bytes);
|
||||
return message.getUniqueId(digest);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -175,22 +172,23 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
case MESSAGE_TYPE_UPDATE:
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
V itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false);
|
||||
View itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false);
|
||||
BindableConversationItem bindable = (BindableConversationItem) itemView;
|
||||
|
||||
itemView.setOnClickListener(view -> {
|
||||
if (clickListener != null) {
|
||||
clickListener.onItemClick(itemView.getMessageRecord());
|
||||
clickListener.onItemClick(bindable.getConversationMessage());
|
||||
}
|
||||
});
|
||||
|
||||
itemView.setOnLongClickListener(view -> {
|
||||
if (clickListener != null) {
|
||||
clickListener.onItemLongClick(itemView, itemView.getMessageRecord());
|
||||
clickListener.onItemLongClick(itemView, bindable.getConversationMessage());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
itemView.setEventListener(clickListener);
|
||||
bindable.setEventListener(clickListener);
|
||||
|
||||
Log.d(TAG, String.format(Locale.US, "Inflate time: %d ms for View type: %d", System.currentTimeMillis() - start, viewType));
|
||||
return new ConversationViewHolder(itemView);
|
||||
@@ -215,24 +213,24 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
|
||||
case MESSAGE_TYPE_UPDATE:
|
||||
ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder;
|
||||
MessageRecord messageRecord = Objects.requireNonNull(getItem(position));
|
||||
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
|
||||
int adapterPosition = holder.getAdapterPosition();
|
||||
|
||||
MessageRecord previousRecord = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
|
||||
MessageRecord nextRecord = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
|
||||
ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
|
||||
ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
|
||||
|
||||
conversationViewHolder.getView().bind(messageRecord,
|
||||
Optional.fromNullable(previousRecord),
|
||||
Optional.fromNullable(nextRecord),
|
||||
glideRequests,
|
||||
locale,
|
||||
selected,
|
||||
recipient,
|
||||
searchQuery,
|
||||
messageRecord == recordToPulseHighlight);
|
||||
conversationViewHolder.getBindable().bind(conversationMessage,
|
||||
Optional.fromNullable(previousMessage != null ? previousMessage.getMessageRecord() : null),
|
||||
Optional.fromNullable(nextMessage != null ? nextMessage.getMessageRecord() : null),
|
||||
glideRequests,
|
||||
locale,
|
||||
selected,
|
||||
recipient,
|
||||
searchQuery,
|
||||
conversationMessage == recordToPulse);
|
||||
|
||||
if (messageRecord == recordToPulseHighlight) {
|
||||
recordToPulseHighlight = null;
|
||||
if (conversationMessage == recordToPulse) {
|
||||
recordToPulse = null;
|
||||
}
|
||||
break;
|
||||
case MESSAGE_TYPE_HEADER:
|
||||
@@ -245,16 +243,18 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
}
|
||||
|
||||
@Override
|
||||
public void submitList(@Nullable PagedList<MessageRecord> pagedList) {
|
||||
public void submitList(@Nullable PagedList<ConversationMessage> pagedList) {
|
||||
cleanFastRecords();
|
||||
super.submitList(pagedList);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable MessageRecord getItem(int position) {
|
||||
protected @Nullable ConversationMessage getItem(int position) {
|
||||
position = hasHeader() ? position - 1 : position;
|
||||
|
||||
if (position < fastRecords.size()) {
|
||||
if (position == -1) {
|
||||
return null;
|
||||
} else if (position < fastRecords.size()) {
|
||||
return fastRecords.get(position);
|
||||
} else {
|
||||
int correctedPosition = position - fastRecords.size();
|
||||
@@ -272,7 +272,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
@Override
|
||||
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
|
||||
if (holder instanceof ConversationViewHolder) {
|
||||
((ConversationViewHolder) holder).getView().unbind();
|
||||
((ConversationViewHolder) holder).getBindable().unbind();
|
||||
} else if (holder instanceof HeaderFooterViewHolder) {
|
||||
((HeaderFooterViewHolder) holder).unbind();
|
||||
}
|
||||
@@ -285,11 +285,11 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
if (position >= getItemCount()) return -1;
|
||||
if (position < 0) return -1;
|
||||
|
||||
MessageRecord record = getItem(position);
|
||||
ConversationMessage conversationMessage = getItem(position);
|
||||
|
||||
if (record == null) return -1;
|
||||
if (conversationMessage == null) return -1;
|
||||
|
||||
calendar.setTime(new Date(record.getDateSent()));
|
||||
calendar.setTime(new Date(conversationMessage.getMessageRecord().getDateSent()));
|
||||
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
|
||||
}
|
||||
|
||||
@@ -300,14 +300,18 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
|
||||
@Override
|
||||
public void onBindHeaderViewHolder(StickyHeaderViewHolder viewHolder, int position) {
|
||||
MessageRecord messageRecord = Objects.requireNonNull(getItem(position));
|
||||
viewHolder.setText(DateUtils.getRelativeDate(viewHolder.itemView.getContext(), locale, messageRecord.getDateReceived()));
|
||||
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
|
||||
viewHolder.setText(DateUtils.getRelativeDate(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateReceived()));
|
||||
}
|
||||
|
||||
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
|
||||
viewHolder.setText(viewHolder.itemView.getContext().getResources().getQuantityString(R.plurals.ConversationAdapter_n_unread_messages, (position + 1), (position + 1)));
|
||||
}
|
||||
|
||||
boolean hasNoConversationMessages() {
|
||||
return super.getItemCount() + fastRecords.size() == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The presence of a header may throw off the position you'd like to jump to. This will return
|
||||
* an adjusted message position based on adapter state.
|
||||
@@ -328,12 +332,12 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
if (position >= getItemCount()) return 0;
|
||||
if (position < 0) return 0;
|
||||
|
||||
MessageRecord messageRecord = getItem(position);
|
||||
ConversationMessage conversationMessage = getItem(position);
|
||||
|
||||
if (messageRecord == null || messageRecord.isOutgoing()) {
|
||||
if (conversationMessage == null || conversationMessage.getMessageRecord().isOutgoing()) {
|
||||
return 0;
|
||||
} else {
|
||||
return messageRecord.getDateReceived();
|
||||
return conversationMessage.getMessageRecord().getDateReceived();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,13 +383,13 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
}
|
||||
|
||||
/**
|
||||
* Momentarily highlights a row at the requested position.
|
||||
* Momentarily highlights a mention at the requested position.
|
||||
*/
|
||||
void pulseHighlightItem(int position) {
|
||||
void pulseAtPosition(int position) {
|
||||
if (position >= 0 && position < getItemCount()) {
|
||||
int correctedPosition = isHeaderPosition(position) ? position + 1 : position;
|
||||
|
||||
recordToPulseHighlight = getItem(correctedPosition);
|
||||
recordToPulse = getItem(correctedPosition);
|
||||
notifyItemChanged(correctedPosition);
|
||||
}
|
||||
}
|
||||
@@ -403,8 +407,8 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
* for a database change.
|
||||
*/
|
||||
@MainThread
|
||||
void addFastRecord(MessageRecord record) {
|
||||
fastRecords.add(0, record);
|
||||
void addFastRecord(ConversationMessage conversationMessage) {
|
||||
fastRecords.add(0, conversationMessage);
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@@ -422,7 +426,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
/**
|
||||
* Returns set of records that are selected in multi-select mode.
|
||||
*/
|
||||
Set<MessageRecord> getSelectedItems() {
|
||||
Set<ConversationMessage> getSelectedItems() {
|
||||
return new HashSet<>(selected);
|
||||
}
|
||||
|
||||
@@ -436,11 +440,11 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
/**
|
||||
* Toggles the selected state of a record in multi-select mode.
|
||||
*/
|
||||
void toggleSelection(MessageRecord record) {
|
||||
if (selected.contains(record)) {
|
||||
selected.remove(record);
|
||||
void toggleSelection(ConversationMessage conversationMessage) {
|
||||
if (selected.contains(conversationMessage)) {
|
||||
selected.remove(conversationMessage);
|
||||
} else {
|
||||
selected.add(record);
|
||||
selected.add(conversationMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,11 +468,11 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
Util.assertMainThread();
|
||||
|
||||
synchronized (releasedFastRecords) {
|
||||
Iterator<MessageRecord> recordIterator = fastRecords.iterator();
|
||||
while (recordIterator.hasNext()) {
|
||||
long id = recordIterator.next().getId();
|
||||
Iterator<ConversationMessage> messageIterator = fastRecords.iterator();
|
||||
while (messageIterator.hasNext()) {
|
||||
long id = messageIterator.next().getMessageRecord().getId();
|
||||
if (releasedFastRecords.contains(id)) {
|
||||
recordIterator.remove();
|
||||
messageIterator.remove();
|
||||
releasedFastRecords.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -510,18 +514,17 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable MessageRecord getLastVisibleMessageRecord(int position) {
|
||||
public @Nullable ConversationMessage getLastVisibleConversationMessage(int position) {
|
||||
return getItem(position - ((hasFooter() && position == getItemCount() - 1) ? 1 : 0));
|
||||
}
|
||||
|
||||
static class ConversationViewHolder extends RecyclerView.ViewHolder {
|
||||
public <V extends View & BindableConversationItem> ConversationViewHolder(final @NonNull V itemView) {
|
||||
public ConversationViewHolder(final @NonNull View itemView) {
|
||||
super(itemView);
|
||||
}
|
||||
|
||||
public <V extends View & BindableConversationItem> V getView() {
|
||||
//noinspection unchecked
|
||||
return (V)itemView;
|
||||
public BindableConversationItem getBindable() {
|
||||
return (BindableConversationItem) itemView;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +533,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
|
||||
StickyHeaderViewHolder(View itemView) {
|
||||
super(itemView);
|
||||
textView = ViewUtil.findById(itemView, R.id.text);
|
||||
textView = itemView.findViewById(R.id.text);
|
||||
}
|
||||
|
||||
StickyHeaderViewHolder(TextView textView) {
|
||||
@@ -571,21 +574,21 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
|
||||
}
|
||||
}
|
||||
|
||||
private static class DiffCallback extends DiffUtil.ItemCallback<MessageRecord> {
|
||||
private static class DiffCallback extends DiffUtil.ItemCallback<ConversationMessage> {
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull MessageRecord oldItem, @NonNull MessageRecord newItem) {
|
||||
return oldItem.isMms() == newItem.isMms() && oldItem.getId() == newItem.getId();
|
||||
public boolean areItemsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
|
||||
return oldItem.getMessageRecord().isMms() == newItem.getMessageRecord().isMms() && oldItem.getMessageRecord().getId() == newItem.getMessageRecord().getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull MessageRecord oldItem, @NonNull MessageRecord newItem) {
|
||||
public boolean areContentsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
|
||||
// Corner rounding is not part of the model, so we can't use this yet
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ItemClickListener extends BindableConversationItem.EventListener {
|
||||
void onItemClick(MessageRecord item);
|
||||
void onItemLongClick(View maskTarget, MessageRecord item);
|
||||
void onItemClick(ConversationMessage item);
|
||||
void onItemLongClick(View maskTarget, ConversationMessage item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,27 +4,35 @@ import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.PositionalDataSource;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
import org.thoughtcrime.securesms.util.paging.SizeFixResult;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* Core data source for loading an individual conversation.
|
||||
*/
|
||||
class ConversationDataSource extends PositionalDataSource<MessageRecord> {
|
||||
class ConversationDataSource extends PositionalDataSource<ConversationMessage> {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationDataSource.class);
|
||||
|
||||
@@ -57,7 +65,7 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<MessageRecord> callback) {
|
||||
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<ConversationMessage> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
@@ -65,43 +73,80 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
|
||||
int totalCount = db.getConversationCount(threadId);
|
||||
int effectiveCount = params.requestedStartPosition;
|
||||
|
||||
MentionHelper mentionHelper = new MentionHelper();
|
||||
|
||||
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) {
|
||||
MessageRecord record;
|
||||
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
|
||||
records.add(record);
|
||||
mentionHelper.add(record);
|
||||
effectiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
mentionHelper.fetchMentions(context);
|
||||
|
||||
if (!isInvalid()) {
|
||||
SizeFixResult<MessageRecord> result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount);
|
||||
|
||||
callback.onResult(result.getItems(), params.requestedStartPosition, result.getTotal());
|
||||
}
|
||||
List<ConversationMessage> items = Stream.of(result.getItems())
|
||||
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId())))
|
||||
.toList();
|
||||
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.requestedStartPosition + ", size: " + params.requestedLoadSize + (isInvalid() ? " -- invalidated" : ""));
|
||||
callback.onResult(items, params.requestedStartPosition, result.getTotal());
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", actualSize: " + result.getItems().size() + ", totalCount: " + result.getTotal());
|
||||
} else {
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", totalCount: " + totalCount + " -- invalidated");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<MessageRecord> callback) {
|
||||
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<ConversationMessage> callback) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
List<MessageRecord> records = new ArrayList<>(params.loadSize);
|
||||
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
List<MessageRecord> records = new ArrayList<>(params.loadSize);
|
||||
MentionHelper mentionHelper = new MentionHelper();
|
||||
|
||||
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) {
|
||||
MessageRecord record;
|
||||
while ((record = reader.getNext()) != null && !isInvalid()) {
|
||||
records.add(record);
|
||||
mentionHelper.add(record);
|
||||
}
|
||||
}
|
||||
|
||||
callback.onResult(records);
|
||||
mentionHelper.fetchMentions(context);
|
||||
|
||||
List<ConversationMessage> items = Stream.of(records)
|
||||
.map(m -> ConversationMessageFactory.createWithUnresolvedData(context, m, mentionHelper.getMentions(m.getId())))
|
||||
.toList();
|
||||
callback.onResult(items);
|
||||
|
||||
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.startPosition + ", size: " + params.loadSize + (isInvalid() ? " -- invalidated" : ""));
|
||||
}
|
||||
|
||||
static class Factory extends DataSource.Factory<Integer, MessageRecord> {
|
||||
private static class MentionHelper {
|
||||
|
||||
private Collection<Long> messageIds = new LinkedList<>();
|
||||
private Map<Long, List<Mention>> messageIdToMentions = new HashMap<>();
|
||||
|
||||
void add(MessageRecord record) {
|
||||
if (record.isMms()) {
|
||||
messageIds.add(record.getId());
|
||||
}
|
||||
}
|
||||
|
||||
void fetchMentions(Context context) {
|
||||
messageIdToMentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessages(messageIds);
|
||||
}
|
||||
|
||||
@Nullable List<Mention> getMentions(long id) {
|
||||
return messageIdToMentions.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
static class Factory extends DataSource.Factory<Integer, ConversationMessage> {
|
||||
|
||||
private final Context context;
|
||||
private final long threadId;
|
||||
@@ -114,7 +159,7 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull DataSource<Integer, MessageRecord> create() {
|
||||
public @NonNull DataSource<Integer, ConversationMessage> create() {
|
||||
return new ConversationDataSource(context, threadId, invalidator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
import android.content.Intent;
|
||||
@@ -25,7 +27,7 @@ import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.ClipboardManager;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
@@ -55,6 +57,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.collect.Sets;
|
||||
|
||||
@@ -63,6 +66,7 @@ import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.components.ConversationScrollToView;
|
||||
import org.thoughtcrime.securesms.components.ConversationTypingView;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
|
||||
@@ -71,8 +75,10 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
|
||||
import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.ItemClickListener;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderViewHolder;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
@@ -81,6 +87,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.longmessage.LongMessageActivity;
|
||||
@@ -126,8 +133,6 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
@@ -151,23 +156,35 @@ public class ConversationFragment extends LoggingFragment {
|
||||
private Locale locale;
|
||||
private RecyclerView list;
|
||||
private RecyclerView.ItemDecoration lastSeenDecoration;
|
||||
private RecyclerView.ItemDecoration stickyHeaderDecoration;
|
||||
private ViewSwitcher topLoadMoreView;
|
||||
private ViewSwitcher bottomLoadMoreView;
|
||||
private ConversationTypingView typingView;
|
||||
private UnknownSenderView unknownSenderView;
|
||||
private View composeDivider;
|
||||
private View scrollToBottomButton;
|
||||
private ConversationScrollToView scrollToBottomButton;
|
||||
private ConversationScrollToView scrollToMentionButton;
|
||||
private TextView scrollDateHeader;
|
||||
private ConversationBannerView conversationBanner;
|
||||
private ConversationBannerView emptyConversationBanner;
|
||||
private MessageRequestViewModel messageRequestViewModel;
|
||||
private MessageCountsViewModel messageCountsViewModel;
|
||||
private ConversationViewModel conversationViewModel;
|
||||
private SnapToTopDataObserver snapToTopDataObserver;
|
||||
private MarkReadHelper markReadHelper;
|
||||
private Animation scrollButtonInAnimation;
|
||||
private Animation mentionButtonInAnimation;
|
||||
private Animation scrollButtonOutAnimation;
|
||||
private Animation mentionButtonOutAnimation;
|
||||
private OnScrollListener conversationScrollListener;
|
||||
private int pulsePosition = -1;
|
||||
|
||||
public static void prepare(@NonNull Context context) {
|
||||
FrameLayout parent = new FrameLayout(context);
|
||||
parent.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_received_text_only, parent, 15);
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_sent_text_only, parent, 15);
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_received_multimedia, parent, 10);
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_sent_multimedia, parent, 10);
|
||||
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_update, parent, 5);
|
||||
@@ -183,13 +200,13 @@ public class ConversationFragment extends LoggingFragment {
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle bundle) {
|
||||
final View view = inflater.inflate(R.layout.conversation_fragment, container, false);
|
||||
list = ViewUtil.findById(view, android.R.id.list);
|
||||
composeDivider = ViewUtil.findById(view, R.id.compose_divider);
|
||||
scrollToBottomButton = ViewUtil.findById(view, R.id.scroll_to_bottom_button);
|
||||
scrollDateHeader = ViewUtil.findById(view, R.id.scroll_date_header);
|
||||
emptyConversationBanner = ViewUtil.findById(view, R.id.empty_conversation_banner);
|
||||
list = view.findViewById(android.R.id.list);
|
||||
composeDivider = view.findViewById(R.id.compose_divider);
|
||||
|
||||
scrollToBottomButton.setOnClickListener(v -> scrollToBottom());
|
||||
scrollToBottomButton = view.findViewById(R.id.scroll_to_bottom);
|
||||
scrollToMentionButton = view.findViewById(R.id.scroll_to_mention);
|
||||
scrollDateHeader = view.findViewById(R.id.scroll_date_header);
|
||||
emptyConversationBanner = view.findViewById(R.id.empty_conversation_banner);
|
||||
|
||||
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
|
||||
list.setHasFixedSize(false);
|
||||
@@ -207,14 +224,16 @@ public class ConversationFragment extends LoggingFragment {
|
||||
typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false);
|
||||
|
||||
new ConversationItemSwipeCallback(
|
||||
messageRecord -> actionMode == null &&
|
||||
MenuState.canReplyToMessage(MenuState.isActionMessage(messageRecord), messageRecord, messageRequestViewModel.shouldShowMessageRequest()),
|
||||
conversationMessage -> actionMode == null &&
|
||||
MenuState.canReplyToMessage(MenuState.isActionMessage(conversationMessage.getMessageRecord()), conversationMessage.getMessageRecord(), messageRequestViewModel.shouldShowMessageRequest()),
|
||||
this::handleReplyMessage
|
||||
).attachToRecyclerView(list);
|
||||
|
||||
setupListLayoutListeners();
|
||||
|
||||
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||
this.messageCountsViewModel = ViewModelProviders.of(requireActivity()).get(MessageCountsViewModel.class);
|
||||
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
|
||||
|
||||
conversationViewModel.getMessages().observe(this, list -> {
|
||||
if (getListAdapter() != null && !list.getDataSource().isInvalid()) {
|
||||
Log.i(TAG, "submitList");
|
||||
@@ -225,6 +244,25 @@ public class ConversationFragment extends LoggingFragment {
|
||||
});
|
||||
conversationViewModel.getConversationMetadata().observe(this, this::presentConversationMetadata);
|
||||
|
||||
conversationViewModel.getShowMentionsButton().observe(this, shouldShow -> {
|
||||
if (shouldShow) {
|
||||
ViewUtil.animateIn(scrollToMentionButton, mentionButtonInAnimation);
|
||||
} else {
|
||||
ViewUtil.animateOut(scrollToMentionButton, mentionButtonOutAnimation, View.INVISIBLE);
|
||||
}
|
||||
});
|
||||
|
||||
conversationViewModel.getShowScrollToBottom().observe(this, shouldShow -> {
|
||||
if (shouldShow) {
|
||||
ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation);
|
||||
} else {
|
||||
ViewUtil.animateOut(scrollToBottomButton, scrollButtonOutAnimation, View.INVISIBLE);
|
||||
}
|
||||
});
|
||||
|
||||
scrollToBottomButton.setOnClickListener(v -> scrollToBottom());
|
||||
scrollToMentionButton.setOnClickListener(v -> scrollToNextMention());
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -260,6 +298,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
public void onActivityCreated(Bundle bundle) {
|
||||
super.onActivityCreated(bundle);
|
||||
|
||||
initializeScrollButtonAnimations();
|
||||
initializeResources();
|
||||
initializeMessageRequestViewModel();
|
||||
initializeListAdapter();
|
||||
@@ -284,10 +323,10 @@ public class ConversationFragment extends LoggingFragment {
|
||||
int firstVisiblePosition = getListLayoutManager().findFirstCompletelyVisibleItemPosition();
|
||||
|
||||
final long lastVisibleMessageTimestamp;
|
||||
if (firstVisiblePosition != 0 && lastVisiblePosition != RecyclerView.NO_POSITION) {
|
||||
MessageRecord message = getListAdapter().getLastVisibleMessageRecord(lastVisiblePosition);
|
||||
if (firstVisiblePosition > 0 && lastVisiblePosition != RecyclerView.NO_POSITION) {
|
||||
ConversationMessage message = getListAdapter().getLastVisibleConversationMessage(lastVisiblePosition);
|
||||
|
||||
lastVisibleMessageTimestamp = message != null ? message.getDateReceived() : 0;
|
||||
lastVisibleMessageTimestamp = message != null ? message.getMessageRecord().getDateReceived() : 0;
|
||||
} else {
|
||||
lastVisibleMessageTimestamp = 0;
|
||||
}
|
||||
@@ -399,7 +438,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
description = context.getString(R.string.MessageRequestProfileView_member_of_many_groups,
|
||||
HtmlUtil.bold(groups.get(0)),
|
||||
HtmlUtil.bold(groups.get(1)),
|
||||
context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_member_of_others, others, others));
|
||||
context.getResources().getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others));
|
||||
}
|
||||
|
||||
conversationBanner.setDescription(HtmlCompat.fromHtml(description, 0));
|
||||
@@ -415,11 +454,19 @@ public class ConversationFragment extends LoggingFragment {
|
||||
this.recipient = Recipient.live(getActivity().getIntent().getParcelableExtra(ConversationActivity.RECIPIENT_EXTRA));
|
||||
this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1);
|
||||
this.unknownSenderView = new UnknownSenderView(getActivity(), recipient.get(), threadId, () -> clearHeaderIfNotTyping(getListAdapter()));
|
||||
this.markReadHelper = new MarkReadHelper(threadId, requireContext());
|
||||
|
||||
conversationViewModel.onConversationDataAvailable(threadId, startingPosition);
|
||||
messageCountsViewModel.setThreadId(threadId);
|
||||
|
||||
OnScrollListener scrollListener = new ConversationScrollListener(getActivity());
|
||||
list.addOnScrollListener(scrollListener);
|
||||
messageCountsViewModel.getUnreadMessagesCount().observe(getViewLifecycleOwner(), scrollToBottomButton::setUnreadCount);
|
||||
messageCountsViewModel.getUnreadMentionsCount().observe(getViewLifecycleOwner(), count -> {
|
||||
scrollToMentionButton.setUnreadCount(count);
|
||||
conversationViewModel.setHasUnreadMentions(count > 0);
|
||||
});
|
||||
|
||||
conversationScrollListener = new ConversationScrollListener(requireContext());
|
||||
list.addOnScrollListener(conversationScrollListener);
|
||||
|
||||
if (oldThreadId != threadId) {
|
||||
ApplicationContext.getInstance(requireContext()).getTypingStatusRepository().getTypists(oldThreadId).removeObservers(this);
|
||||
@@ -431,7 +478,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
Log.d(TAG, "Initializing adapter for " + recipient.getId());
|
||||
ConversationAdapter adapter = new ConversationAdapter(GlideApp.with(this), locale, selectionClickListener, this.recipient.get());
|
||||
list.setAdapter(adapter);
|
||||
list.addItemDecoration(new StickyHeaderDecoration(adapter, false, false));
|
||||
setStickyHeaderDecoration(adapter);
|
||||
ConversationAdapter.initializePool(list.getRecycledViewPool());
|
||||
|
||||
adapter.registerAdapterDataObserver(snapToTopDataObserver);
|
||||
@@ -516,14 +563,14 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void setCorrectMenuVisibility(@NonNull Menu menu) {
|
||||
Set<MessageRecord> messageRecords = getListAdapter().getSelectedItems();
|
||||
Set<ConversationMessage> messages = getListAdapter().getSelectedItems();
|
||||
|
||||
if (actionMode != null && messageRecords.size() == 0) {
|
||||
if (actionMode != null && messages.size() == 0) {
|
||||
actionMode.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
MenuState menuState = MenuState.getMenuState(messageRecords, messageRequestViewModel.shouldShowMessageRequest());
|
||||
MenuState menuState = MenuState.getMenuState(Stream.of(messages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest());
|
||||
|
||||
menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction());
|
||||
menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction());
|
||||
@@ -541,8 +588,8 @@ public class ConversationFragment extends LoggingFragment {
|
||||
return (SmoothScrollingLinearLayoutManager) list.getLayoutManager();
|
||||
}
|
||||
|
||||
private MessageRecord getSelectedMessageRecord() {
|
||||
Set<MessageRecord> messageRecords = getListAdapter().getSelectedItems();
|
||||
private ConversationMessage getSelectedConversationMessage() {
|
||||
Set<ConversationMessage> messageRecords = getListAdapter().getSelectedItems();
|
||||
|
||||
if (messageRecords.size() == 1) return messageRecords.iterator().next();
|
||||
else throw new AssertionError();
|
||||
@@ -557,6 +604,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
|
||||
snapToTopDataObserver.requestScrollPosition(0);
|
||||
conversationViewModel.onConversationDataAvailable(threadId, -1);
|
||||
messageCountsViewModel.setThreadId(threadId);
|
||||
initializeListAdapter();
|
||||
}
|
||||
}
|
||||
@@ -569,6 +617,15 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
public void setStickyHeaderDecoration(@NonNull ConversationAdapter adapter) {
|
||||
if (stickyHeaderDecoration != null) {
|
||||
list.removeItemDecoration(stickyHeaderDecoration);
|
||||
}
|
||||
|
||||
stickyHeaderDecoration = new StickyHeaderDecoration(adapter, false, false);
|
||||
list.addItemDecoration(stickyHeaderDecoration);
|
||||
}
|
||||
|
||||
public void setLastSeen(long lastSeen) {
|
||||
if (lastSeenDecoration != null) {
|
||||
list.removeItemDecoration(lastSeenDecoration);
|
||||
@@ -578,37 +635,30 @@ public class ConversationFragment extends LoggingFragment {
|
||||
list.addItemDecoration(lastSeenDecoration);
|
||||
}
|
||||
|
||||
private void handleCopyMessage(final Set<MessageRecord> messageRecords) {
|
||||
List<MessageRecord> messageList = new LinkedList<>(messageRecords);
|
||||
Collections.sort(messageList, new Comparator<MessageRecord>() {
|
||||
@Override
|
||||
public int compare(MessageRecord lhs, MessageRecord rhs) {
|
||||
if (lhs.getDateReceived() < rhs.getDateReceived()) return -1;
|
||||
else if (lhs.getDateReceived() == rhs.getDateReceived()) return 0;
|
||||
else return 1;
|
||||
}
|
||||
});
|
||||
private void handleCopyMessage(final Set<ConversationMessage> conversationMessages) {
|
||||
List<ConversationMessage> messageList = new ArrayList<>(conversationMessages);
|
||||
Collections.sort(messageList, (lhs, rhs) -> Long.compare(lhs.getMessageRecord().getDateReceived(), rhs.getMessageRecord().getDateReceived()));
|
||||
|
||||
StringBuilder bodyBuilder = new StringBuilder();
|
||||
ClipboardManager clipboard = (ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
SpannableStringBuilder bodyBuilder = new SpannableStringBuilder();
|
||||
ClipboardManager clipboard = (ClipboardManager) requireActivity().getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
|
||||
for (MessageRecord messageRecord : messageList) {
|
||||
String body = messageRecord.getDisplayBody(requireContext()).toString();
|
||||
for (ConversationMessage message : messageList) {
|
||||
CharSequence body = message.getDisplayBody(requireContext());
|
||||
if (!TextUtils.isEmpty(body)) {
|
||||
bodyBuilder.append(body).append('\n');
|
||||
if (bodyBuilder.length() > 0) {
|
||||
bodyBuilder.append('\n');
|
||||
}
|
||||
bodyBuilder.append(body);
|
||||
}
|
||||
}
|
||||
if (bodyBuilder.length() > 0 && bodyBuilder.charAt(bodyBuilder.length() - 1) == '\n') {
|
||||
bodyBuilder.deleteCharAt(bodyBuilder.length() - 1);
|
||||
|
||||
if (!TextUtils.isEmpty(bodyBuilder)) {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(null, bodyBuilder));
|
||||
}
|
||||
|
||||
String result = bodyBuilder.toString();
|
||||
|
||||
if (!TextUtils.isEmpty(result))
|
||||
clipboard.setText(result);
|
||||
}
|
||||
|
||||
private void handleDeleteMessages(final Set<MessageRecord> messageRecords) {
|
||||
private void handleDeleteMessages(final Set<ConversationMessage> conversationMessages) {
|
||||
Set<MessageRecord> messageRecords = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet());
|
||||
if (FeatureFlags.remoteDelete()) {
|
||||
buildRemoteDeleteConfirmationDialog(messageRecords).show();
|
||||
} else {
|
||||
@@ -643,6 +693,8 @@ public class ConversationFragment extends LoggingFragment {
|
||||
|
||||
if (threadDeleted) {
|
||||
threadId = -1;
|
||||
conversationViewModel.clearThreadId();
|
||||
messageCountsViewModel.clearThreadId();
|
||||
listener.setThreadId(threadId);
|
||||
}
|
||||
}
|
||||
@@ -682,6 +734,8 @@ public class ConversationFragment extends LoggingFragment {
|
||||
|
||||
if (threadDeleted) {
|
||||
threadId = -1;
|
||||
conversationViewModel.clearThreadId();
|
||||
messageCountsViewModel.clearThreadId();
|
||||
listener.setThreadId(threadId);
|
||||
}
|
||||
}
|
||||
@@ -692,26 +746,42 @@ public class ConversationFragment extends LoggingFragment {
|
||||
});
|
||||
|
||||
if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis())) {
|
||||
builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone, (dialog, which) -> {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
for (MessageRecord message : messageRecords) {
|
||||
MessageSender.sendRemoteDelete(context, message.getId(), message.isMms());
|
||||
}
|
||||
});
|
||||
});
|
||||
builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone, (dialog, which) -> handleDeleteForEveryone(messageRecords));
|
||||
}
|
||||
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
return builder;
|
||||
}
|
||||
|
||||
private void handleDeleteForEveryone(Set<MessageRecord> messageRecords) {
|
||||
Runnable deleteForEveryone = () -> {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
for (MessageRecord message : messageRecords) {
|
||||
MessageSender.sendRemoteDelete(ApplicationDependencies.getApplication(), message.getId(), message.isMms());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private void handleDisplayDetails(MessageRecord message) {
|
||||
startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message, recipient.getId(), threadId));
|
||||
if (SignalStore.uiHints().hasConfirmedDeleteForEveryoneOnce()) {
|
||||
deleteForEveryone.run();
|
||||
} else {
|
||||
new AlertDialog.Builder(requireActivity())
|
||||
.setMessage(R.string.ConversationFragment_this_message_will_be_permanently_deleted_for_everyone)
|
||||
.setPositiveButton(R.string.ConversationFragment_delete_for_everyone, (dialog, which) -> {
|
||||
SignalStore.uiHints().markHasConfirmedDeleteForEveryoneOnce();
|
||||
deleteForEveryone.run();
|
||||
})
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleForwardMessage(MessageRecord message) {
|
||||
if (message.isViewOnce()) {
|
||||
private void handleDisplayDetails(ConversationMessage message) {
|
||||
startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message.getMessageRecord(), recipient.getId(), threadId));
|
||||
}
|
||||
|
||||
private void handleForwardMessage(ConversationMessage conversationMessage) {
|
||||
if (conversationMessage.getMessageRecord().isViewOnce()) {
|
||||
throw new AssertionError("Cannot forward a view-once message.");
|
||||
}
|
||||
|
||||
@@ -719,10 +789,10 @@ public class ConversationFragment extends LoggingFragment {
|
||||
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
Intent composeIntent = new Intent(getActivity(), ShareActivity.class);
|
||||
composeIntent.putExtra(Intent.EXTRA_TEXT, message.getDisplayBody(requireContext()).toString());
|
||||
composeIntent.putExtra(Intent.EXTRA_TEXT, conversationMessage.getDisplayBody(requireContext()));
|
||||
|
||||
if (message.isMms()) {
|
||||
MmsMessageRecord mediaMessage = (MmsMessageRecord) message;
|
||||
if (conversationMessage.getMessageRecord().isMms()) {
|
||||
MmsMessageRecord mediaMessage = (MmsMessageRecord) conversationMessage.getMessageRecord();
|
||||
boolean isAlbum = mediaMessage.containsMediaSlide() &&
|
||||
mediaMessage.getSlideDeck().getSlides().size() > 1 &&
|
||||
mediaMessage.getSlideDeck().getAudioSlide() == null &&
|
||||
@@ -747,11 +817,12 @@ public class ConversationFragment extends LoggingFragment {
|
||||
attachment.getHeight(),
|
||||
attachment.getSize(),
|
||||
0,
|
||||
attachment.isBorderless(),
|
||||
Optional.absent(),
|
||||
Optional.fromNullable(attachment.getCaption()),
|
||||
Optional.absent()));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!mediaList.isEmpty()) {
|
||||
composeIntent.putExtra(ConversationActivity.MEDIA_EXTRA, mediaList);
|
||||
@@ -760,6 +831,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
Slide slide = mediaMessage.getSlideDeck().getSlides().get(0);
|
||||
composeIntent.putExtra(Intent.EXTRA_STREAM, slide.getUri());
|
||||
composeIntent.setType(slide.getContentType());
|
||||
composeIntent.putExtra(ConversationActivity.BORDERLESS_EXTRA, slide.isBorderless());
|
||||
|
||||
if (slide.hasSticker()) {
|
||||
composeIntent.putExtra(ConversationActivity.STICKER_EXTRA, slide.asAttachment().getSticker());
|
||||
@@ -791,7 +863,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, message);
|
||||
}
|
||||
|
||||
private void handleReplyMessage(final MessageRecord message) {
|
||||
private void handleReplyMessage(final ConversationMessage message) {
|
||||
if (getActivity() != null) {
|
||||
//noinspection ConstantConditions
|
||||
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
|
||||
@@ -837,7 +909,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
if (getListAdapter() != null) {
|
||||
clearHeaderIfNotTyping(getListAdapter());
|
||||
setLastSeen(0);
|
||||
getListAdapter().addFastRecord(messageRecord);
|
||||
getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord, messageRecord.getDisplayBody(requireContext()), message.getMentions()));
|
||||
list.post(() -> list.scrollToPosition(0));
|
||||
}
|
||||
|
||||
@@ -850,7 +922,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
if (getListAdapter() != null) {
|
||||
clearHeaderIfNotTyping(getListAdapter());
|
||||
setLastSeen(0);
|
||||
getListAdapter().addFastRecord(messageRecord);
|
||||
getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord));
|
||||
list.post(() -> list.scrollToPosition(0));
|
||||
}
|
||||
|
||||
@@ -889,6 +961,8 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
listener.onCursorChanged();
|
||||
|
||||
conversationScrollListener.onScrolled(list, 0, 0);
|
||||
};
|
||||
|
||||
int lastSeenPosition = adapter.getAdapterPositionForMessagePosition(conversation.getLastSeenPosition());
|
||||
@@ -900,7 +974,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
snapToTopDataObserver.buildScrollPosition(conversation.getJumpToPosition())
|
||||
.withOnScrollRequestComplete(() -> {
|
||||
afterScroll.run();
|
||||
getListAdapter().pulseHighlightItem(conversation.getJumpToPosition());
|
||||
getListAdapter().pulseAtPosition(conversation.getJumpToPosition());
|
||||
})
|
||||
.submit();
|
||||
} else if (conversation.isMessageRequestAccepted()) {
|
||||
@@ -938,27 +1012,40 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
public void jumpToMessage(@NonNull RecipientId author, long timestamp, @Nullable Runnable onMessageNotFound) {
|
||||
SimpleTask.run(getLifecycle(), () -> {
|
||||
return DatabaseFactory.getMmsSmsDatabase(getContext())
|
||||
.getMessagePositionInConversation(threadId, timestamp, author);
|
||||
}, p -> moveToMessagePosition(p + (isTypingIndicatorShowing() ? 1 : 0), onMessageNotFound));
|
||||
}, p -> moveToPosition(p + (isTypingIndicatorShowing() ? 1 : 0), onMessageNotFound));
|
||||
}
|
||||
|
||||
private void moveToMessagePosition(int position, @Nullable Runnable onMessageNotFound) {
|
||||
private void moveToPosition(int position, @Nullable Runnable onMessageNotFound) {
|
||||
conversationViewModel.onConversationDataAvailable(threadId, position);
|
||||
snapToTopDataObserver.buildScrollPosition(position)
|
||||
.withOnPerformScroll(((layoutManager, p) ->
|
||||
list.post(() -> {
|
||||
layoutManager.scrollToPosition(p);
|
||||
getListAdapter().pulseHighlightItem(position);
|
||||
})
|
||||
list.post(() -> {
|
||||
if (Math.abs(layoutManager.findFirstVisibleItemPosition() - p) < SCROLL_ANIMATION_THRESHOLD) {
|
||||
View child = layoutManager.findViewByPosition(position);
|
||||
|
||||
if (child != null && layoutManager.isViewPartiallyVisible(child, true, false)) {
|
||||
getListAdapter().pulseAtPosition(position);
|
||||
} else {
|
||||
pulsePosition = position;
|
||||
}
|
||||
|
||||
list.smoothScrollToPosition(p);
|
||||
} else {
|
||||
layoutManager.scrollToPosition(p);
|
||||
getListAdapter().pulseAtPosition(position);
|
||||
}
|
||||
})
|
||||
))
|
||||
.withOnInvalidPosition(() -> {
|
||||
if (onMessageNotFound != null) {
|
||||
onMessageNotFound.run();
|
||||
}
|
||||
Log.w(TAG, "[moveToMessagePosition] Tried to navigate to message, but it wasn't found.");
|
||||
Log.w(TAG, "[moveToMentionPosition] Tried to navigate to mention, but it wasn't found.");
|
||||
})
|
||||
.submit();
|
||||
}
|
||||
@@ -977,9 +1064,51 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void initializeScrollButtonAnimations() {
|
||||
scrollButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in);
|
||||
scrollButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out);
|
||||
|
||||
mentionButtonInAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_in);
|
||||
mentionButtonOutAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_scale_out);
|
||||
|
||||
scrollButtonInAnimation.setDuration(100);
|
||||
scrollButtonOutAnimation.setDuration(50);
|
||||
|
||||
mentionButtonInAnimation.setDuration(100);
|
||||
mentionButtonOutAnimation.setDuration(50);
|
||||
}
|
||||
|
||||
private void scrollToNextMention() {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(ApplicationDependencies.getApplication());
|
||||
return mmsDatabase.getOldestUnreadMentionDetails(threadId);
|
||||
}, (pair) -> {
|
||||
if (pair != null) {
|
||||
jumpToMessage(pair.first, pair.second, () -> {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void postMarkAsReadRequest() {
|
||||
if (getListAdapter().hasNoConversationMessages()) {
|
||||
return;
|
||||
}
|
||||
|
||||
int position = getListLayoutManager().findFirstVisibleItemPosition();
|
||||
if (position >= (isTypingIndicatorShowing() ? 1 : 0)) {
|
||||
ConversationMessage item = getListAdapter().getItem(position);
|
||||
if (item != null) {
|
||||
long timestamp = item.getMessageRecord()
|
||||
.getDateReceived();
|
||||
|
||||
markReadHelper.onViewsRevealed(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface ConversationFragmentListener {
|
||||
void setThreadId(long threadId);
|
||||
void handleReplyMessage(MessageRecord messageRecord);
|
||||
void handleReplyMessage(ConversationMessage conversationMessage);
|
||||
void onMessageActionToolbarOpened();
|
||||
void onForwardClicked();
|
||||
void onMessageRequest(@NonNull MessageRequestViewModel viewModel);
|
||||
@@ -990,51 +1119,47 @@ public class ConversationFragment extends LoggingFragment {
|
||||
void onCursorChanged();
|
||||
void onListVerticalTranslationChanged(float translationY);
|
||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||
void handleReactionDetails(@NonNull View maskTarget);
|
||||
}
|
||||
|
||||
private class ConversationScrollListener extends OnScrollListener {
|
||||
|
||||
private final Animation scrollButtonInAnimation;
|
||||
private final Animation scrollButtonOutAnimation;
|
||||
private final ConversationDateHeader conversationDateHeader;
|
||||
|
||||
private boolean wasAtBottom = true;
|
||||
private boolean wasAtZoomScrollHeight = false;
|
||||
private long lastPositionId = -1;
|
||||
|
||||
ConversationScrollListener(@NonNull Context context) {
|
||||
this.scrollButtonInAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_in);
|
||||
this.scrollButtonOutAnimation = AnimationUtils.loadAnimation(context, R.anim.fade_scale_out);
|
||||
this.conversationDateHeader = new ConversationDateHeader(context, scrollDateHeader);
|
||||
|
||||
this.scrollButtonInAnimation.setDuration(100);
|
||||
this.scrollButtonOutAnimation.setDuration(50);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScrolled(@NonNull final RecyclerView rv, final int dx, final int dy) {
|
||||
boolean currentlyAtBottom = isAtBottom();
|
||||
boolean currentlyAtBottom = !rv.canScrollVertically(1);
|
||||
boolean currentlyAtZoomScrollHeight = isAtZoomScrollHeight();
|
||||
int positionId = getHeaderPositionId();
|
||||
|
||||
if (currentlyAtBottom && !wasAtBottom) {
|
||||
ViewUtil.fadeOut(composeDivider, 50, View.INVISIBLE);
|
||||
ViewUtil.animateOut(scrollToBottomButton, scrollButtonOutAnimation, View.INVISIBLE);
|
||||
} else if (!currentlyAtBottom && wasAtBottom) {
|
||||
ViewUtil.fadeIn(composeDivider, 500);
|
||||
}
|
||||
|
||||
if (currentlyAtZoomScrollHeight && !wasAtZoomScrollHeight) {
|
||||
ViewUtil.animateIn(scrollToBottomButton, scrollButtonInAnimation);
|
||||
if (currentlyAtBottom) {
|
||||
conversationViewModel.setShowScrollButtons(false);
|
||||
} else if (currentlyAtZoomScrollHeight) {
|
||||
conversationViewModel.setShowScrollButtons(true);
|
||||
}
|
||||
|
||||
if (positionId != lastPositionId) {
|
||||
bindScrollHeader(conversationDateHeader, positionId);
|
||||
}
|
||||
|
||||
wasAtBottom = currentlyAtBottom;
|
||||
wasAtZoomScrollHeight = currentlyAtZoomScrollHeight;
|
||||
lastPositionId = positionId;
|
||||
wasAtBottom = currentlyAtBottom;
|
||||
lastPositionId = positionId;
|
||||
|
||||
postMarkAsReadRequest();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1043,6 +1168,11 @@ public class ConversationFragment extends LoggingFragment {
|
||||
conversationDateHeader.show();
|
||||
} else if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
conversationDateHeader.hide();
|
||||
|
||||
if (pulsePosition != -1) {
|
||||
getListAdapter().pulseAtPosition(pulsePosition);
|
||||
pulsePosition = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,9 +1194,9 @@ public class ConversationFragment extends LoggingFragment {
|
||||
private class ConversationFragmentItemClickListener implements ItemClickListener {
|
||||
|
||||
@Override
|
||||
public void onItemClick(MessageRecord messageRecord) {
|
||||
public void onItemClick(ConversationMessage conversationMessage) {
|
||||
if (actionMode != null) {
|
||||
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
|
||||
((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage);
|
||||
list.getAdapter().notifyDataSetChanged();
|
||||
|
||||
if (getListAdapter().getSelectedItems().size() == 0) {
|
||||
@@ -1079,10 +1209,12 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemLongClick(View maskTarget, MessageRecord messageRecord) {
|
||||
public void onItemLongClick(View maskTarget, ConversationMessage conversationMessage) {
|
||||
|
||||
if (actionMode != null) return;
|
||||
|
||||
MessageRecord messageRecord = conversationMessage.getMessageRecord();
|
||||
|
||||
if (messageRecord.isSecure() &&
|
||||
!messageRecord.isRemoteDelete() &&
|
||||
!messageRecord.isUpdate() &&
|
||||
@@ -1092,12 +1224,12 @@ public class ConversationFragment extends LoggingFragment {
|
||||
{
|
||||
isReacting = true;
|
||||
list.setLayoutFrozen(true);
|
||||
listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(messageRecord), () -> {
|
||||
listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(conversationMessage), () -> {
|
||||
isReacting = false;
|
||||
list.setLayoutFrozen(false);
|
||||
});
|
||||
} else {
|
||||
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
|
||||
((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage);
|
||||
list.getAdapter().notifyDataSetChanged();
|
||||
|
||||
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
|
||||
@@ -1122,7 +1254,7 @@ public class ConversationFragment extends LoggingFragment {
|
||||
.getQuotedMessagePosition(threadId,
|
||||
messageRecord.getQuote().getId(),
|
||||
messageRecord.getQuote().getAuthor());
|
||||
}, p -> moveToMessagePosition(p + (isTypingIndicatorShowing() ? 1 : 0), () -> {
|
||||
}, p -> moveToPosition(p + (isTypingIndicatorShowing() ? 1 : 0), () -> {
|
||||
Toast.makeText(getContext(), R.string.ConversationFragment_quoted_message_no_longer_available, Toast.LENGTH_SHORT).show();
|
||||
}));
|
||||
}
|
||||
@@ -1238,14 +1370,15 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReactionClicked(long messageId, boolean isMms) {
|
||||
public void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms) {
|
||||
if (getContext() == null) return;
|
||||
|
||||
listener.handleReactionDetails(reactionTarget);
|
||||
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) {
|
||||
public void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId) {
|
||||
if (getContext() == null) return;
|
||||
|
||||
RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(requireFragmentManager(), "BOTTOM");
|
||||
@@ -1255,6 +1388,11 @@ public class ConversationFragment extends LoggingFragment {
|
||||
public void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord) {
|
||||
listener.onMessageWithErrorClicked(messageRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onUrlClicked(@NonNull String url) {
|
||||
return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1266,8 +1404,8 @@ public class ConversationFragment extends LoggingFragment {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleEnterMultiSelect(@NonNull MessageRecord messageRecord) {
|
||||
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
|
||||
private void handleEnterMultiSelect(@NonNull ConversationMessage conversationMessage) {
|
||||
((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage);
|
||||
list.getAdapter().notifyDataSetChanged();
|
||||
|
||||
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
|
||||
@@ -1321,23 +1459,23 @@ public class ConversationFragment extends LoggingFragment {
|
||||
|
||||
private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener {
|
||||
|
||||
private final MessageRecord messageRecord;
|
||||
private final ConversationMessage conversationMessage;
|
||||
|
||||
private ReactionsToolbarListener(@NonNull MessageRecord messageRecord) {
|
||||
this.messageRecord = messageRecord;
|
||||
private ReactionsToolbarListener(@NonNull ConversationMessage conversationMessage) {
|
||||
this.conversationMessage = conversationMessage;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemClick(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.action_info: handleDisplayDetails(messageRecord); return true;
|
||||
case R.id.action_delete: handleDeleteMessages(Sets.newHashSet(messageRecord)); return true;
|
||||
case R.id.action_copy: handleCopyMessage(Sets.newHashSet(messageRecord)); return true;
|
||||
case R.id.action_reply: handleReplyMessage(messageRecord); return true;
|
||||
case R.id.action_multiselect: handleEnterMultiSelect(messageRecord); return true;
|
||||
case R.id.action_forward: handleForwardMessage(messageRecord); return true;
|
||||
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) messageRecord); return true;
|
||||
default: return false;
|
||||
case R.id.action_info: handleDisplayDetails(conversationMessage); return true;
|
||||
case R.id.action_delete: handleDeleteMessages(Sets.newHashSet(conversationMessage)); return true;
|
||||
case R.id.action_copy: handleCopyMessage(Sets.newHashSet(conversationMessage)); return true;
|
||||
case R.id.action_reply: handleReplyMessage(conversationMessage); return true;
|
||||
case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true;
|
||||
case R.id.action_forward: handleForwardMessage(conversationMessage); return true;
|
||||
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1396,24 +1534,24 @@ public class ConversationFragment extends LoggingFragment {
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.menu_context_details:
|
||||
handleDisplayDetails(getSelectedMessageRecord());
|
||||
handleDisplayDetails(getSelectedConversationMessage());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.menu_context_forward:
|
||||
handleForwardMessage(getSelectedMessageRecord());
|
||||
handleForwardMessage(getSelectedConversationMessage());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.menu_context_resend:
|
||||
handleResendMessage(getSelectedMessageRecord());
|
||||
handleResendMessage(getSelectedConversationMessage().getMessageRecord());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.menu_context_save_attachment:
|
||||
handleSaveAttachment((MediaMmsMessageRecord)getSelectedMessageRecord());
|
||||
handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
case R.id.menu_context_reply:
|
||||
maybeShowSwipeToReplyTooltip();
|
||||
handleReplyMessage(getSelectedMessageRecord());
|
||||
handleReplyMessage(getSelectedConversationMessage());
|
||||
actionMode.finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.animation.ValueAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
@@ -26,6 +27,7 @@ import android.graphics.PorterDuff;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.Typeface;
|
||||
import android.net.Uri;
|
||||
import android.text.Annotation;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
@@ -36,7 +38,6 @@ import android.text.style.BackgroundColorSpan;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.text.style.ClickableSpan;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.URLSpan;
|
||||
import android.text.util.Linkify;
|
||||
@@ -52,6 +53,7 @@ import androidx.annotation.DimenRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
@@ -63,6 +65,7 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.components.AlertView;
|
||||
import org.thoughtcrime.securesms.components.AudioView;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.components.BorderlessImageView;
|
||||
import org.thoughtcrime.securesms.components.ConversationItemFooter;
|
||||
import org.thoughtcrime.securesms.components.ConversationItemThumbnail;
|
||||
import org.thoughtcrime.securesms.components.DocumentView;
|
||||
@@ -70,8 +73,8 @@ import org.thoughtcrime.securesms.components.LinkPreviewView;
|
||||
import org.thoughtcrime.securesms.components.Outliner;
|
||||
import org.thoughtcrime.securesms.components.QuoteView;
|
||||
import org.thoughtcrime.securesms.components.SharedContactView;
|
||||
import org.thoughtcrime.securesms.components.StickerView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
@@ -107,21 +110,26 @@ import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.LongClickCopySpan;
|
||||
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
|
||||
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.VibrateUtil;
|
||||
import org.thoughtcrime.securesms.util.UrlClickHandler;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme;
|
||||
|
||||
/**
|
||||
* A view that displays an individual conversation item within a conversation
|
||||
* thread. Used by ComposeMessageActivity's ListActivity via a ConversationAdapter.
|
||||
@@ -140,11 +148,13 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
|
||||
private static final Rect SWIPE_RECT = new Rect();
|
||||
|
||||
private MessageRecord messageRecord;
|
||||
private Locale locale;
|
||||
private boolean groupThread;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
private ConversationMessage conversationMessage;
|
||||
private MessageRecord messageRecord;
|
||||
private Locale locale;
|
||||
private boolean groupThread;
|
||||
private LiveRecipient recipient;
|
||||
private GlideRequests glideRequests;
|
||||
private ValueAnimator pulseOutlinerAlphaAnimator;
|
||||
|
||||
protected ConversationItemBodyBubble bodyBubble;
|
||||
protected View reply;
|
||||
@@ -161,15 +171,17 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
private ViewGroup container;
|
||||
protected ReactionsConversationView reactionsView;
|
||||
|
||||
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
|
||||
private @NonNull Outliner outliner = new Outliner();
|
||||
private @NonNull Set<ConversationMessage> batchSelected = new HashSet<>();
|
||||
private @NonNull Outliner outliner = new Outliner();
|
||||
private @NonNull Outliner pulseOutliner = new Outliner();
|
||||
private @NonNull List<Outliner> outliners = new ArrayList<>(2);
|
||||
private LiveRecipient conversationRecipient;
|
||||
private Stub<ConversationItemThumbnail> mediaThumbnailStub;
|
||||
private Stub<AudioView> audioViewStub;
|
||||
private Stub<DocumentView> documentViewStub;
|
||||
private Stub<SharedContactView> sharedContactStub;
|
||||
private Stub<LinkPreviewView> linkPreviewStub;
|
||||
private Stub<StickerView> stickerStub;
|
||||
private Stub<BorderlessImageView> stickerStub;
|
||||
private Stub<ViewOnceMessageView> revealableStub;
|
||||
private @Nullable EventListener eventListener;
|
||||
|
||||
@@ -183,6 +195,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
|
||||
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
|
||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
|
||||
private final Context context;
|
||||
|
||||
@@ -235,22 +248,23 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull MessageRecord messageRecord,
|
||||
public void bind(@NonNull ConversationMessage conversationMessage,
|
||||
@NonNull Optional<MessageRecord> previousMessageRecord,
|
||||
@NonNull Optional<MessageRecord> nextMessageRecord,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<MessageRecord> batchSelected,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseHighlight)
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<ConversationMessage> batchSelected,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulse)
|
||||
{
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
|
||||
|
||||
conversationRecipient = conversationRecipient.resolve();
|
||||
|
||||
this.messageRecord = messageRecord;
|
||||
this.conversationMessage = conversationMessage;
|
||||
this.messageRecord = conversationMessage.getMessageRecord();
|
||||
this.locale = locale;
|
||||
this.glideRequests = glideRequests;
|
||||
this.batchSelected = batchSelected;
|
||||
@@ -264,9 +278,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
setGutterSizes(messageRecord, groupThread);
|
||||
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
|
||||
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, conversationRecipient, groupThread);
|
||||
setInteractionState(messageRecord, pulseHighlight);
|
||||
setBodyText(messageRecord, searchQuery);
|
||||
setBubbleState(messageRecord);
|
||||
setInteractionState(conversationMessage, pulse);
|
||||
setStatusIcons(messageRecord);
|
||||
setContactPhoto(recipient.get());
|
||||
setGroupMessageStatus(messageRecord, recipient.get());
|
||||
@@ -380,20 +394,21 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
if (conversationRecipient != null) {
|
||||
conversationRecipient.removeForeverObserver(this);
|
||||
}
|
||||
cancelPulseOutlinerAnimation();
|
||||
}
|
||||
|
||||
public MessageRecord getMessageRecord() {
|
||||
return messageRecord;
|
||||
public ConversationMessage getConversationMessage() {
|
||||
return conversationMessage;
|
||||
}
|
||||
|
||||
/// MessageRecord Attribute Parsers
|
||||
|
||||
private void setBubbleState(MessageRecord messageRecord) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
if (messageRecord.isOutgoing() && !messageRecord.isRemoteDelete()) {
|
||||
bodyBubble.getBackground().setColorFilter(defaultBubbleColor, PorterDuff.Mode.MULTIPLY);
|
||||
footer.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_text_secondary_color));
|
||||
footer.setIconColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_icon_color));
|
||||
} else if (isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord)) {
|
||||
} else if (messageRecord.isRemoteDelete() || (isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord))) {
|
||||
bodyBubble.getBackground().setColorFilter(ThemeUtil.getThemedColor(context, R.attr.conversation_item_reveal_viewed_background_color), PorterDuff.Mode.MULTIPLY);
|
||||
footer.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_text_secondary_color));
|
||||
footer.setIconColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_sent_icon_color));
|
||||
@@ -404,7 +419,21 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
}
|
||||
|
||||
outliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_sent_text_secondary_color));
|
||||
bodyBubble.setOutliner(shouldDrawBodyBubbleOutline(messageRecord) ? outliner : null);
|
||||
|
||||
pulseOutliner.setColor(ThemeUtil.getThemedColor(getContext(), R.attr.conversation_item_mention_pulse_color));
|
||||
pulseOutliner.setStrokeWidth(ViewUtil.dpToPx(4));
|
||||
|
||||
outliners.clear();
|
||||
if (shouldDrawBodyBubbleOutline(messageRecord)) {
|
||||
outliners.add(outliner);
|
||||
}
|
||||
outliners.add(pulseOutliner);
|
||||
|
||||
bodyBubble.setOutliners(outliners);
|
||||
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
mediaThumbnailStub.get().setPulseOutliner(pulseOutliner);
|
||||
}
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
setAudioViewTint(messageRecord);
|
||||
@@ -425,38 +454,61 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
}
|
||||
}
|
||||
|
||||
private void setInteractionState(MessageRecord messageRecord, boolean pulseHighlight) {
|
||||
if (batchSelected.contains(messageRecord)) {
|
||||
private void setInteractionState(ConversationMessage conversationMessage, boolean pulseMention) {
|
||||
if (batchSelected.contains(conversationMessage)) {
|
||||
setBackgroundResource(R.drawable.conversation_item_background);
|
||||
setSelected(true);
|
||||
} else if (pulseHighlight) {
|
||||
setBackgroundResource(R.drawable.conversation_item_background_animated);
|
||||
setSelected(true);
|
||||
postDelayed(() -> setSelected(false), 500);
|
||||
} else if (pulseMention) {
|
||||
setBackground(null);
|
||||
setSelected(false);
|
||||
startPulseOutlinerAnimation();
|
||||
} else {
|
||||
setSelected(false);
|
||||
}
|
||||
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
mediaThumbnailStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
|
||||
mediaThumbnailStub.get().setClickable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
|
||||
mediaThumbnailStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
|
||||
mediaThumbnailStub.get().setClickable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
|
||||
mediaThumbnailStub.get().setLongClickable(batchSelected.isEmpty());
|
||||
}
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
audioViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
|
||||
audioViewStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
|
||||
audioViewStub.get().setClickable(batchSelected.isEmpty());
|
||||
audioViewStub.get().setEnabled(batchSelected.isEmpty());
|
||||
}
|
||||
|
||||
if (documentViewStub.resolved()) {
|
||||
documentViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
|
||||
documentViewStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
|
||||
documentViewStub.get().setClickable(batchSelected.isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
private void startPulseOutlinerAnimation() {
|
||||
pulseOutlinerAlphaAnimator = ValueAnimator.ofInt(0, 0x66, 0).setDuration(600);
|
||||
pulseOutlinerAlphaAnimator.addUpdateListener(animator -> {
|
||||
pulseOutliner.setAlpha((Integer) animator.getAnimatedValue());
|
||||
bodyBubble.invalidate();
|
||||
|
||||
if (mediaThumbnailStub.resolved()) {
|
||||
mediaThumbnailStub.get().invalidate();
|
||||
}
|
||||
});
|
||||
pulseOutlinerAlphaAnimator.start();
|
||||
}
|
||||
|
||||
private void cancelPulseOutlinerAnimation() {
|
||||
if (pulseOutlinerAlphaAnimator != null) {
|
||||
pulseOutlinerAlphaAnimator.cancel();
|
||||
pulseOutlinerAlphaAnimator = null;
|
||||
}
|
||||
|
||||
pulseOutliner.setAlpha(0);
|
||||
}
|
||||
|
||||
private boolean shouldDrawBodyBubbleOutline(MessageRecord messageRecord) {
|
||||
return !messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord);
|
||||
boolean isIncomingViewedOnce = !messageRecord.isOutgoing() && isViewOnceMessage(messageRecord) && ViewOnceUtil.isViewed((MmsMessageRecord) messageRecord);
|
||||
return isIncomingViewedOnce || messageRecord.isRemoteDelete();
|
||||
}
|
||||
|
||||
private boolean isCaptionlessMms(MessageRecord messageRecord) {
|
||||
@@ -475,12 +527,20 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
return messageRecord.isMms() && ((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() != null;
|
||||
}
|
||||
|
||||
private boolean isBorderless(MessageRecord messageRecord) {
|
||||
//noinspection ConstantConditions
|
||||
return isCaptionlessMms(messageRecord) &&
|
||||
hasThumbnail(messageRecord) &&
|
||||
((MmsMessageRecord)messageRecord).getSlideDeck().getThumbnailSlide().isBorderless();
|
||||
}
|
||||
|
||||
private boolean hasOnlyThumbnail(MessageRecord messageRecord) {
|
||||
return hasThumbnail(messageRecord) &&
|
||||
!hasAudio(messageRecord) &&
|
||||
!hasDocument(messageRecord) &&
|
||||
!hasSharedContact(messageRecord) &&
|
||||
!hasSticker(messageRecord) &&
|
||||
!isBorderless(messageRecord) &&
|
||||
!isViewOnceMessage(messageRecord);
|
||||
}
|
||||
|
||||
@@ -522,23 +582,29 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
return messageRecord.isMms() && ((MmsMessageRecord) messageRecord).isViewOnce();
|
||||
}
|
||||
|
||||
private void setBodyText(MessageRecord messageRecord, @Nullable String searchQuery) {
|
||||
private void setBodyText(@NonNull MessageRecord messageRecord,
|
||||
@Nullable String searchQuery)
|
||||
{
|
||||
bodyText.setClickable(false);
|
||||
bodyText.setFocusable(false);
|
||||
bodyText.setTextSize(TypedValue.COMPLEX_UNIT_SP, TextSecurePreferences.getMessageBodyTextSize(context));
|
||||
bodyText.setMovementMethod(LongClickMovementMethod.getInstance(getContext()));
|
||||
|
||||
if (messageRecord.isRemoteDelete()) {
|
||||
String deletedMessage = context.getString(R.string.ConversationItem_this_message_was_deleted);
|
||||
String deletedMessage = context.getString(messageRecord.isOutgoing() ? R.string.ConversationItem_you_deleted_this_message : R.string.ConversationItem_this_message_was_deleted);
|
||||
SpannableString italics = new SpannableString(deletedMessage);
|
||||
italics.setSpan(new RelativeSizeSpan(0.9f), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
italics.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, deletedMessage.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
italics.setSpan(new ForegroundColorSpan(ThemeUtil.getThemedColor(context, R.attr.conversation_item_delete_for_everyone_text_color)),
|
||||
0,
|
||||
deletedMessage.length(),
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
|
||||
bodyText.setText(italics);
|
||||
bodyText.setVisibility(View.VISIBLE);
|
||||
} else if (isCaptionlessMms(messageRecord)) {
|
||||
bodyText.setVisibility(View.GONE);
|
||||
} else {
|
||||
Spannable styledText = linkifyMessageBody(messageRecord.getDisplayBody(getContext()), batchSelected.isEmpty());
|
||||
Spannable styledText = linkifyMessageBody(conversationMessage.getDisplayBody(getContext()), batchSelected.isEmpty());
|
||||
styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery);
|
||||
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery);
|
||||
|
||||
@@ -548,6 +614,12 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
bodyText.setOverflowText(null);
|
||||
}
|
||||
|
||||
if (messageRecord.isOutgoing()) {
|
||||
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
|
||||
} else {
|
||||
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_40));
|
||||
}
|
||||
|
||||
bodyText.setText(styledText);
|
||||
bodyText.setVisibility(View.VISIBLE);
|
||||
}
|
||||
@@ -670,7 +742,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
footer.setVisibility(VISIBLE);
|
||||
} else if (hasSticker(messageRecord) && isCaptionlessMms(messageRecord)) {
|
||||
} else if ((hasSticker(messageRecord) && isCaptionlessMms(messageRecord)) || isBorderless(messageRecord)) {
|
||||
bodyBubble.setBackgroundColor(Color.TRANSPARENT);
|
||||
|
||||
stickerStub.get().setVisibility(View.VISIBLE);
|
||||
@@ -681,9 +753,16 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
stickerStub.get().setSticker(glideRequests, ((MmsMessageRecord) messageRecord).getSlideDeck().getStickerSlide());
|
||||
stickerStub.get().setThumbnailClickListener(new StickerClickListener());
|
||||
if (hasSticker(messageRecord)) {
|
||||
//noinspection ConstantConditions
|
||||
stickerStub.get().setSlide(glideRequests, ((MmsMessageRecord) messageRecord).getSlideDeck().getStickerSlide());
|
||||
stickerStub.get().setThumbnailClickListener(new StickerClickListener());
|
||||
} else {
|
||||
//noinspection ConstantConditions
|
||||
stickerStub.get().setSlide(glideRequests, ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlide());
|
||||
stickerStub.get().setThumbnailClickListener((v, slide) -> performClick());
|
||||
}
|
||||
|
||||
stickerStub.get().setDownloadClickListener(downloadClickListener);
|
||||
stickerStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
stickerStub.get().setOnClickListener(passthroughClickListener);
|
||||
@@ -701,7 +780,6 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
|
||||
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
|
||||
mediaThumbnailStub.get().setImageResource(glideRequests,
|
||||
thumbnailSlides,
|
||||
@@ -835,14 +913,16 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
|
||||
contactPhoto.setOnClickListener(v -> {
|
||||
if (eventListener != null) {
|
||||
eventListener.onGroupMemberAvatarClicked(recipientId, conversationRecipient.get().requireGroupId());
|
||||
eventListener.onGroupMemberClicked(recipientId, conversationRecipient.get().requireGroupId());
|
||||
}
|
||||
});
|
||||
|
||||
contactPhoto.setAvatar(glideRequests, recipient, false);
|
||||
}
|
||||
|
||||
private SpannableString linkifyMessageBody(SpannableString messageBody, boolean shouldLinkifyAllLinks) {
|
||||
private SpannableString linkifyMessageBody(@NonNull SpannableString messageBody,
|
||||
boolean shouldLinkifyAllLinks)
|
||||
{
|
||||
int linkPattern = Linkify.WEB_URLS | Linkify.EMAIL_ADDRESSES | Linkify.PHONE_NUMBERS;
|
||||
boolean hasLinks = Linkify.addLinks(messageBody, shouldLinkifyAllLinks ? linkPattern : 0);
|
||||
|
||||
@@ -854,11 +934,18 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
URLSpan[] urlSpans = messageBody.getSpans(0, messageBody.length(), URLSpan.class);
|
||||
|
||||
for (URLSpan urlSpan : urlSpans) {
|
||||
int start = messageBody.getSpanStart(urlSpan);
|
||||
int end = messageBody.getSpanEnd(urlSpan);
|
||||
messageBody.setSpan(new LongClickCopySpan(urlSpan.getURL()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
int start = messageBody.getSpanStart(urlSpan);
|
||||
int end = messageBody.getSpanEnd(urlSpan);
|
||||
URLSpan span = new InterceptableLongClickCopyLinkSpan(urlSpan.getURL(), urlClickListener);
|
||||
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
List<Annotation> mentionAnnotations = MentionAnnotation.getMentionAnnotations(messageBody);
|
||||
for (Annotation annotation : mentionAnnotations) {
|
||||
messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
|
||||
return messageBody;
|
||||
}
|
||||
|
||||
@@ -881,7 +968,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
}
|
||||
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
|
||||
//noinspection ConstantConditions
|
||||
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getText(), quote.isOriginalMissing(), quote.getAttachment());
|
||||
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getDisplayText(), quote.isOriginalMissing(), quote.getAttachment());
|
||||
quoteView.setVisibility(View.VISIBLE);
|
||||
quoteView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
|
||||
|
||||
@@ -950,7 +1037,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
reactionsView.setOnClickListener(v -> {
|
||||
if (eventListener == null) return;
|
||||
|
||||
eventListener.onReactionClicked(current.getId(), current.isMms());
|
||||
eventListener.onReactionClicked(this, current.getId(), current.isMms());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -974,7 +1061,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
}
|
||||
|
||||
private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) {
|
||||
if (hasSticker(messageRecord)) {
|
||||
if (hasSticker(messageRecord) || isBorderless(messageRecord)) {
|
||||
return stickerFooter;
|
||||
} else if (hasSharedContact(messageRecord)) {
|
||||
return sharedContactStub.get().getFooter();
|
||||
@@ -1010,7 +1097,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
if (shouldDrawBodyBubbleOutline(messageRecord)) {
|
||||
groupSender.setTextColor(stickerAuthorColor);
|
||||
groupSenderProfileName.setTextColor(stickerAuthorColor);
|
||||
} else if (hasSticker(messageRecord)) {
|
||||
} else if (hasSticker(messageRecord) || isBorderless(messageRecord)) {
|
||||
groupSender.setTextColor(stickerAuthorColor);
|
||||
groupSenderProfileName.setTextColor(stickerAuthorColor);
|
||||
} else {
|
||||
@@ -1059,33 +1146,41 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
if (current.isOutgoing()) {
|
||||
background = R.drawable.message_bubble_background_sent_alone;
|
||||
outliner.setRadius(bigRadius);
|
||||
pulseOutliner.setRadius(bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_alone;
|
||||
outliner.setRadius(bigRadius);
|
||||
pulseOutliner.setRadius(bigRadius);
|
||||
}
|
||||
} else if (isStartOfMessageCluster(current, previous, isGroupThread)) {
|
||||
if (current.isOutgoing()) {
|
||||
background = R.drawable.message_bubble_background_sent_start;
|
||||
outliner.setRadii(bigRadius, bigRadius, smallRadius, bigRadius);
|
||||
pulseOutliner.setRadii(bigRadius, bigRadius, smallRadius, bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_start;
|
||||
outliner.setRadii(bigRadius, bigRadius, bigRadius, smallRadius);
|
||||
pulseOutliner.setRadii(bigRadius, bigRadius, bigRadius, smallRadius);
|
||||
}
|
||||
} else if (isEndOfMessageCluster(current, next, isGroupThread)) {
|
||||
if (current.isOutgoing()) {
|
||||
background = R.drawable.message_bubble_background_sent_end;
|
||||
outliner.setRadii(bigRadius, smallRadius, bigRadius, bigRadius);
|
||||
pulseOutliner.setRadii(bigRadius, smallRadius, bigRadius, bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_end;
|
||||
outliner.setRadii(smallRadius, bigRadius, bigRadius, bigRadius);
|
||||
pulseOutliner.setRadii(smallRadius, bigRadius, bigRadius, bigRadius);
|
||||
}
|
||||
} else {
|
||||
if (current.isOutgoing()) {
|
||||
background = R.drawable.message_bubble_background_sent_middle;
|
||||
outliner.setRadii(bigRadius, smallRadius, smallRadius, bigRadius);
|
||||
pulseOutliner.setRadii(bigRadius, smallRadius, smallRadius, bigRadius);
|
||||
} else {
|
||||
background = R.drawable.message_bubble_background_received_middle;
|
||||
outliner.setRadii(smallRadius, bigRadius, bigRadius, smallRadius);
|
||||
pulseOutliner.setRadii(smallRadius, bigRadius, bigRadius, smallRadius);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1307,7 +1402,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
public void onClick(View v, Slide slide) {
|
||||
if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) {
|
||||
performClick();
|
||||
} else if (eventListener != null && hasSticker(messageRecord)){
|
||||
} else if (eventListener != null && hasSticker(messageRecord)) {
|
||||
//noinspection ConstantConditions
|
||||
eventListener.onStickerClicked(((MmsMessageRecord) messageRecord).getSlideDeck().getStickerSlide().asAttachment().getSticker());
|
||||
}
|
||||
@@ -1385,6 +1480,33 @@ public class ConversationItem extends LinearLayout implements BindableConversati
|
||||
}
|
||||
}
|
||||
|
||||
private final class UrlClickListener implements UrlClickHandler {
|
||||
|
||||
@Override
|
||||
public boolean handleOnClick(@NonNull String url) {
|
||||
return eventListener != null && eventListener.onUrlClicked(url);
|
||||
}
|
||||
}
|
||||
|
||||
private class MentionClickableSpan extends ClickableSpan {
|
||||
private final RecipientId mentionedRecipientId;
|
||||
|
||||
MentionClickableSpan(RecipientId mentionedRecipientId) {
|
||||
this.mentionedRecipientId = mentionedRecipientId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(@NonNull View widget) {
|
||||
if (eventListener != null && !Recipient.resolved(mentionedRecipientId).isLocalNumber()) {
|
||||
VibrateUtil.vibrateTick(context);
|
||||
eventListener.onGroupMemberClicked(mentionedRecipientId, conversationRecipient.get().requireGroupId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateDrawState(@NonNull TextPaint ds) { }
|
||||
}
|
||||
|
||||
private void handleMessageApproval() {
|
||||
final int title;
|
||||
final int message;
|
||||
|
||||
@@ -5,13 +5,18 @@ import android.graphics.Canvas;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.components.Outliner;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class ConversationItemBodyBubble extends LinearLayout {
|
||||
|
||||
@Nullable private Outliner outliner;
|
||||
@Nullable private List<Outliner> outliners = Collections.emptyList();
|
||||
@Nullable private OnSizeChangedListener sizeChangedListener;
|
||||
|
||||
public ConversationItemBodyBubble(Context context) {
|
||||
@@ -26,8 +31,8 @@ public class ConversationItemBodyBubble extends LinearLayout {
|
||||
super(context, attrs, defStyleAttr);
|
||||
}
|
||||
|
||||
public void setOutliner(@Nullable Outliner outliner) {
|
||||
this.outliner = outliner;
|
||||
public void setOutliners(@NonNull List<Outliner> outliners) {
|
||||
this.outliners = outliners;
|
||||
}
|
||||
|
||||
public void setOnSizeChangedListener(@Nullable OnSizeChangedListener listener) {
|
||||
@@ -38,9 +43,11 @@ public class ConversationItemBodyBubble extends LinearLayout {
|
||||
protected void onDraw(Canvas canvas) {
|
||||
super.onDraw(canvas);
|
||||
|
||||
if (outliner == null) return;
|
||||
if (Util.isEmpty(outliners)) return;
|
||||
|
||||
outliner.draw(canvas, 0, getMeasuredWidth(), getMeasuredHeight(), 0);
|
||||
for (Outliner outliner : outliners) {
|
||||
outliner.draw(canvas, 0, getMeasuredWidth(), getMeasuredHeight(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -114,8 +114,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
if (cannotSwipeViewHolder(viewHolder)) return;
|
||||
|
||||
ConversationItem item = ((ConversationItem) viewHolder.itemView);
|
||||
MessageRecord messageRecord = item.getMessageRecord();
|
||||
ConversationItem item = ((ConversationItem) viewHolder.itemView);
|
||||
ConversationMessage messageRecord = item.getConversationMessage();
|
||||
|
||||
onSwipeListener.onSwipe(messageRecord);
|
||||
}
|
||||
@@ -169,7 +169,7 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
if (!(viewHolder.itemView instanceof ConversationItem)) return true;
|
||||
|
||||
ConversationItem item = ((ConversationItem) viewHolder.itemView);
|
||||
return !swipeAvailabilityProvider.isSwipeAvailable(item.getMessageRecord()) ||
|
||||
return !swipeAvailabilityProvider.isSwipeAvailable(item.getConversationMessage()) ||
|
||||
item.disallowSwipe(latestDownX, latestDownY);
|
||||
}
|
||||
|
||||
@@ -192,10 +192,10 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
|
||||
}
|
||||
|
||||
interface SwipeAvailabilityProvider {
|
||||
boolean isSwipeAvailable(MessageRecord messageRecord);
|
||||
boolean isSwipeAvailable(ConversationMessage conversationMessage);
|
||||
}
|
||||
|
||||
interface OnSwipeListener {
|
||||
void onSwipe(MessageRecord messageRecord);
|
||||
void onSwipe(ConversationMessage conversationMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.SpannableString;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MentionUtil;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.util.Conversions;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A view level model used to pass arbitrary message related information needed
|
||||
* for various presentations.
|
||||
*/
|
||||
public class ConversationMessage {
|
||||
@NonNull private final MessageRecord messageRecord;
|
||||
@NonNull private final List<Mention> mentions;
|
||||
@Nullable private final SpannableString body;
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord) {
|
||||
this(messageRecord, null, null);
|
||||
}
|
||||
|
||||
private ConversationMessage(@NonNull MessageRecord messageRecord,
|
||||
@Nullable CharSequence body,
|
||||
@Nullable List<Mention> mentions)
|
||||
{
|
||||
this.messageRecord = messageRecord;
|
||||
this.body = body != null ? SpannableString.valueOf(body) : null;
|
||||
this.mentions = mentions != null ? mentions : Collections.emptyList();
|
||||
|
||||
if (!this.mentions.isEmpty() && this.body != null) {
|
||||
MentionAnnotation.setMentionAnnotations(this.body, this.mentions);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull MessageRecord getMessageRecord() {
|
||||
return messageRecord;
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentions() {
|
||||
return mentions;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (o == null || getClass() != o.getClass()) return false;
|
||||
final ConversationMessage that = (ConversationMessage) o;
|
||||
return messageRecord.equals(that.messageRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return messageRecord.hashCode();
|
||||
}
|
||||
|
||||
public long getUniqueId(@NonNull MessageDigest digest) {
|
||||
String unique = (messageRecord.isMms() ? "MMS::" : "SMS::") + messageRecord.getId();
|
||||
byte[] bytes = digest.digest(unique.getBytes());
|
||||
|
||||
return Conversions.byteArrayToLong(bytes);
|
||||
}
|
||||
|
||||
public @NonNull SpannableString getDisplayBody(Context context) {
|
||||
if (mentions.isEmpty() || body == null) {
|
||||
return messageRecord.getDisplayBody(context);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory providing multiple ways of creating {@link ConversationMessage}s.
|
||||
*/
|
||||
public static class ConversationMessageFactory {
|
||||
|
||||
/**
|
||||
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord. No database or
|
||||
* heavy work performed as the message is assumed to not have any mentions.
|
||||
*/
|
||||
@AnyThread
|
||||
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord) {
|
||||
return new ConversationMessage(messageRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord, potentially annotated body, and
|
||||
* list of actual mentions. No database or heavy work performed as the body and mentions are assumed to be
|
||||
* fully updated with display names.
|
||||
*
|
||||
* @param body Contains appropriate {@link MentionAnnotation}s and is updated with actual profile names.
|
||||
* @param mentions List of actual mentions (i.e., not placeholder) matching annotation ranges in body.
|
||||
*/
|
||||
@AnyThread
|
||||
public static @NonNull ConversationMessage createWithResolvedData(@NonNull MessageRecord messageRecord, @Nullable CharSequence body, @Nullable List<Mention> mentions) {
|
||||
if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) {
|
||||
return new ConversationMessage(messageRecord, body, mentions);
|
||||
}
|
||||
return createWithResolvedData(messageRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord and will update and modify the provided
|
||||
* mentions from placeholder to actual. This method may perform database operations to resolve mentions to display names.
|
||||
*
|
||||
* @param mentions List of placeholder mentions to be used to update the body in the provided MessageRecord.
|
||||
*/
|
||||
@WorkerThread
|
||||
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord, @Nullable List<Mention> mentions) {
|
||||
if (messageRecord.isMms() && mentions != null && !mentions.isEmpty()) {
|
||||
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions);
|
||||
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions());
|
||||
}
|
||||
return createWithResolvedData(messageRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@link ConversationMessage} wrapping the provided MessageRecord, and will query for potential mentions. If mentions
|
||||
* are found, the body of the provided message will be updated and modified to match actual mentions. This will perform
|
||||
* database operations to query for mentions and then to resolve mentions to display names.
|
||||
*/
|
||||
@WorkerThread
|
||||
public static @NonNull ConversationMessage createWithUnresolvedData(@NonNull Context context, @NonNull MessageRecord messageRecord) {
|
||||
if (messageRecord.isMms()) {
|
||||
List<Mention> mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(messageRecord.getId());
|
||||
if (!mentions.isEmpty()) {
|
||||
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, messageRecord, mentions);
|
||||
return new ConversationMessage(messageRecord, updated.getBody(), updated.getMentions());
|
||||
}
|
||||
}
|
||||
return createWithResolvedData(messageRecord);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import android.animation.AnimatorSet;
|
||||
import android.app.Activity;
|
||||
import android.content.Context;
|
||||
import android.graphics.PointF;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.Rect;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.HapticFeedbackConstants;
|
||||
@@ -19,7 +21,6 @@ import android.widget.RelativeLayout;
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
@@ -32,10 +33,11 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.components.MaskView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -91,6 +93,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
private OnHideListener onHideListener;
|
||||
|
||||
private AnimatorSet revealAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet revealMaskAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet();
|
||||
private AnimatorSet hideMaskAnimatorSet = new AnimatorSet();
|
||||
@@ -185,16 +188,31 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
maskView.setTarget(maskTarget);
|
||||
|
||||
hideAnimatorSet.end();
|
||||
toolbar.setVisibility(VISIBLE);
|
||||
setVisibility(View.VISIBLE);
|
||||
revealAnimatorSet.start();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
this.activity = activity;
|
||||
originalStatusBarColor = activity.getWindow().getStatusBarColor();
|
||||
activity.getWindow().setStatusBarColor(ContextCompat.getColor(activity, R.color.action_mode_status_bar));
|
||||
activity.getWindow().setStatusBarColor(ThemeUtil.getThemedColor(getContext(), R.attr.reactions_overlay_toolbar_background_color));
|
||||
|
||||
if (!ThemeUtil.isDarkTheme(getContext()) && Build.VERSION.SDK_INT >= 23) {
|
||||
activity.getWindow().getDecorView().setSystemUiVisibility(activity.getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) {
|
||||
maskView.setPadding(0, maskPaddingTop, 0, maskPaddingBottom);
|
||||
maskView.setTarget(maskTarget);
|
||||
|
||||
hideAnimatorSet.end();
|
||||
toolbar.setVisibility(GONE);
|
||||
setVisibility(VISIBLE);
|
||||
revealMaskAnimatorSet.start();
|
||||
}
|
||||
|
||||
public void hide() {
|
||||
maskView.setTarget(null);
|
||||
hideInternal(hideAnimatorSet, onHideListener);
|
||||
@@ -218,8 +236,9 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
revealAnimatorSet.end();
|
||||
hideAnimatorSet.start();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
|
||||
if (Build.VERSION.SDK_INT >= 23 && activity != null) {
|
||||
activity.getWindow().setStatusBarColor(originalStatusBarColor);
|
||||
activity.getWindow().getDecorView().setSystemUiVisibility(activity.getWindow().getDecorView().getSystemUiVisibility() & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
|
||||
activity = null;
|
||||
}
|
||||
|
||||
@@ -358,7 +377,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
view.setTranslationY(0);
|
||||
|
||||
boolean isAtCustomIndex = i == customEmojiIndex;
|
||||
boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && ReactionEmoji.values()[i].emoji.equals(oldEmoji);
|
||||
boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && oldEmoji != null && ReactionEmoji.values()[i].emoji.equals(EmojiUtil.getCanonicalRepresentation(oldEmoji));
|
||||
boolean isAtCustomIndexAndOldEmojiExists = isAtCustomIndex && oldEmoji != null;
|
||||
|
||||
if (!foundSelected &&
|
||||
@@ -379,13 +398,13 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
view.setImageEmoji(oldEmoji);
|
||||
view.setTag(oldEmoji);
|
||||
} else {
|
||||
view.setImageEmoji(ReactionEmoji.values()[i].emoji);
|
||||
view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[i].emoji));
|
||||
}
|
||||
} else if (isAtCustomIndex) {
|
||||
view.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.ic_any_emoji_32));
|
||||
view.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.reactions_overlay_custom_emoji_icon));
|
||||
view.setTag(null);
|
||||
} else {
|
||||
view.setImageEmoji(ReactionEmoji.values()[i].emoji);
|
||||
view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[i].emoji));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -447,7 +466,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
if (selected == customEmojiIndex) {
|
||||
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
|
||||
} else {
|
||||
onReactionSelectedListener.onReactionSelected(messageRecord, ReactionEmoji.values()[selected].emoji);
|
||||
onReactionSelectedListener.onReactionSelected(messageRecord, SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[selected].emoji));
|
||||
}
|
||||
} else {
|
||||
hide();
|
||||
@@ -534,6 +553,9 @@ public final class ConversationReactionOverlay extends RelativeLayout {
|
||||
revealAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
revealAnimatorSet.playTogether(reveals);
|
||||
|
||||
revealMaskAnimatorSet.setInterpolator(INTERPOLATOR);
|
||||
revealMaskAnimatorSet.playTogether(overlayRevealAnim);
|
||||
|
||||
List<Animator> hides = Stream.of(emojiViews)
|
||||
.mapIndexed((idx, v) -> {
|
||||
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -11,6 +12,8 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
@@ -54,15 +57,15 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
public void onFinishInflate() {
|
||||
super.onFinishInflate();
|
||||
|
||||
this.content = ViewUtil.findById(this, R.id.content);
|
||||
this.title = ViewUtil.findById(this, R.id.title);
|
||||
this.subtitle = ViewUtil.findById(this, R.id.subtitle);
|
||||
this.verified = ViewUtil.findById(this, R.id.verified_indicator);
|
||||
this.subtitleContainer = ViewUtil.findById(this, R.id.subtitle_container);
|
||||
this.verifiedSubtitle = ViewUtil.findById(this, R.id.verified_subtitle);
|
||||
this.avatar = ViewUtil.findById(this, R.id.contact_photo_image);
|
||||
this.expirationBadgeContainer = ViewUtil.findById(this, R.id.expiration_badge_container);
|
||||
this.expirationBadgeTime = ViewUtil.findById(this, R.id.expiration_badge);
|
||||
this.content = findViewById(R.id.content);
|
||||
this.title = findViewById(R.id.title);
|
||||
this.subtitle = findViewById(R.id.subtitle);
|
||||
this.verified = findViewById(R.id.verified_indicator);
|
||||
this.subtitleContainer = findViewById(R.id.subtitle_container);
|
||||
this.verifiedSubtitle = findViewById(R.id.verified_subtitle);
|
||||
this.avatar = findViewById(R.id.contact_photo_image);
|
||||
this.expirationBadgeContainer = findViewById(R.id.expiration_badge_container);
|
||||
this.expirationBadgeTime = findViewById(R.id.expiration_badge);
|
||||
|
||||
ViewUtil.setTextViewGravityStart(this.title, getContext());
|
||||
ViewUtil.setTextViewGravityStart(this.subtitle, getContext());
|
||||
@@ -85,14 +88,22 @@ public class ConversationTitleView extends RelativeLayout {
|
||||
if (recipient == null) setComposeTitle();
|
||||
else setRecipientTitle(recipient);
|
||||
|
||||
int startDrawable = 0;
|
||||
int endDrawable = 0;
|
||||
|
||||
if (recipient != null && recipient.isBlocked()) {
|
||||
title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_white_18dp, 0, 0, 0);
|
||||
startDrawable = R.drawable.ic_block_white_18dp;
|
||||
} else if (recipient != null && recipient.isMuted()) {
|
||||
title.setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_white_18dp, 0, 0, 0);
|
||||
} else {
|
||||
title.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
|
||||
startDrawable = R.drawable.ic_volume_off_white_18dp;
|
||||
}
|
||||
|
||||
if (recipient != null && recipient.isSystemContact() && !recipient.isLocalNumber()) {
|
||||
endDrawable = R.drawable.ic_profile_circle_outline_16;
|
||||
}
|
||||
|
||||
title.setCompoundDrawablesRelativeWithIntrinsicBounds(startDrawable, 0, endDrawable, 0);
|
||||
TextViewCompat.setCompoundDrawableTintList(title, ColorStateList.valueOf(ContextCompat.getColor(getContext(), R.color.transparent_white_90)));
|
||||
|
||||
if (recipient != null) {
|
||||
this.avatar.setAvatar(glideRequests, recipient, false);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.graphics.ColorFilter;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.text.SpannableString;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.ImageView;
|
||||
@@ -13,43 +14,55 @@ import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.VerifyIdentityActivity;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.UpdateDescription;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public class ConversationUpdateItem extends LinearLayout
|
||||
implements RecipientForeverObserver, BindableConversationItem
|
||||
public final class ConversationUpdateItem extends LinearLayout
|
||||
implements RecipientForeverObserver,
|
||||
BindableConversationItem,
|
||||
Observer<SpannableString>
|
||||
{
|
||||
private static final String TAG = ConversationUpdateItem.class.getSimpleName();
|
||||
|
||||
private Set<MessageRecord> batchSelected;
|
||||
private Set<ConversationMessage> batchSelected;
|
||||
|
||||
private ImageView icon;
|
||||
private TextView title;
|
||||
private TextView body;
|
||||
private TextView date;
|
||||
private LiveRecipient sender;
|
||||
private MessageRecord messageRecord;
|
||||
private Locale locale;
|
||||
private ImageView icon;
|
||||
private TextView title;
|
||||
private TextView body;
|
||||
private TextView date;
|
||||
private LiveRecipient sender;
|
||||
private ConversationMessage conversationMessage;
|
||||
private MessageRecord messageRecord;
|
||||
private Locale locale;
|
||||
private LiveData<SpannableString> displayBody;
|
||||
|
||||
private final Debouncer bodyClearDebouncer = new Debouncer(150);
|
||||
|
||||
public ConversationUpdateItem(Context context) {
|
||||
super(context);
|
||||
@@ -72,19 +85,19 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull MessageRecord messageRecord,
|
||||
public void bind(@NonNull ConversationMessage conversationMessage,
|
||||
@NonNull Optional<MessageRecord> previousMessageRecord,
|
||||
@NonNull Optional<MessageRecord> nextMessageRecord,
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<MessageRecord> batchSelected,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseUpdate)
|
||||
@NonNull GlideRequests glideRequests,
|
||||
@NonNull Locale locale,
|
||||
@NonNull Set<ConversationMessage> batchSelected,
|
||||
@NonNull Recipient conversationRecipient,
|
||||
@Nullable String searchQuery,
|
||||
boolean pulseMention)
|
||||
{
|
||||
this.batchSelected = batchSelected;
|
||||
|
||||
bind(messageRecord, locale);
|
||||
bind(conversationMessage, locale);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -99,45 +112,73 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageRecord getMessageRecord() {
|
||||
return messageRecord;
|
||||
public ConversationMessage getConversationMessage() {
|
||||
return conversationMessage;
|
||||
}
|
||||
|
||||
private void bind(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
|
||||
private void bind(@NonNull ConversationMessage conversationMessage, @NonNull Locale locale) {
|
||||
if (this.sender != null) {
|
||||
this.sender.removeForeverObserver(this);
|
||||
}
|
||||
|
||||
if (this.messageRecord != null && messageRecord.isGroupAction()) {
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this);
|
||||
}
|
||||
observeDisplayBody(null);
|
||||
setBodyText(null);
|
||||
|
||||
this.messageRecord = messageRecord;
|
||||
this.sender = messageRecord.getIndividualRecipient().live();
|
||||
this.locale = locale;
|
||||
this.conversationMessage = conversationMessage;
|
||||
this.messageRecord = conversationMessage.getMessageRecord();
|
||||
this.sender = messageRecord.getIndividualRecipient().live();
|
||||
this.locale = locale;
|
||||
|
||||
this.sender.observeForever(this);
|
||||
|
||||
if (this.messageRecord != null && messageRecord.isGroupAction()) {
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).addObserver(this);
|
||||
}
|
||||
UpdateDescription updateDescription = Objects.requireNonNull(messageRecord.getUpdateDisplayBody(getContext()));
|
||||
LiveData<String> liveUpdateMessage = LiveUpdateMessage.fromMessageDescription(updateDescription);
|
||||
LiveData<SpannableString> spannableStringMessage = Transformations.map(liveUpdateMessage, SpannableString::new);
|
||||
|
||||
present(messageRecord);
|
||||
present(conversationMessage);
|
||||
|
||||
observeDisplayBody(spannableStringMessage);
|
||||
}
|
||||
|
||||
private void present(MessageRecord messageRecord) {
|
||||
if (messageRecord.isGroupAction()) setGroupRecord(messageRecord);
|
||||
private void observeDisplayBody(@Nullable LiveData<SpannableString> displayBody) {
|
||||
if (this.displayBody != displayBody) {
|
||||
if (this.displayBody != null) {
|
||||
this.displayBody.removeObserver(this);
|
||||
}
|
||||
|
||||
this.displayBody = displayBody;
|
||||
|
||||
if (this.displayBody != null) {
|
||||
this.displayBody.observeForever(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void setBodyText(@Nullable CharSequence text) {
|
||||
if (text == null) {
|
||||
bodyClearDebouncer.publish(() -> body.setText(null));
|
||||
} else {
|
||||
bodyClearDebouncer.clear();
|
||||
body.setText(text);
|
||||
body.setVisibility(VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void present(ConversationMessage conversationMessage) {
|
||||
MessageRecord messageRecord = conversationMessage.getMessageRecord();
|
||||
if (messageRecord.isGroupAction()) setGroupRecord();
|
||||
else if (messageRecord.isCallLog()) setCallRecord(messageRecord);
|
||||
else if (messageRecord.isJoined()) setJoinedRecord(messageRecord);
|
||||
else if (messageRecord.isJoined()) setJoinedRecord();
|
||||
else if (messageRecord.isExpirationTimerUpdate()) setTimerRecord(messageRecord);
|
||||
else if (messageRecord.isEndSession()) setEndSessionRecord(messageRecord);
|
||||
else if (messageRecord.isIdentityUpdate()) setIdentityRecord(messageRecord);
|
||||
else if (messageRecord.isEndSession()) setEndSessionRecord();
|
||||
else if (messageRecord.isIdentityUpdate()) setIdentityRecord();
|
||||
else if (messageRecord.isIdentityVerified() ||
|
||||
messageRecord.isIdentityDefault()) setIdentityVerifyUpdate(messageRecord);
|
||||
else if (messageRecord.isProfileChange()) setProfileNameChangeRecord();
|
||||
else throw new AssertionError("Neither group nor log nor joined.");
|
||||
|
||||
if (batchSelected.contains(messageRecord)) setSelected(true);
|
||||
else setSelected(false);
|
||||
if (batchSelected.contains(conversationMessage)) setSelected(true);
|
||||
else setSelected(false);
|
||||
}
|
||||
|
||||
private void setCallRecord(MessageRecord messageRecord) {
|
||||
@@ -145,11 +186,9 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
else if (messageRecord.isOutgoingCall()) icon.setImageResource(R.drawable.ic_call_made_grey600_24dp);
|
||||
else icon.setImageResource(R.drawable.ic_call_missed_grey600_24dp);
|
||||
|
||||
body.setText(messageRecord.getDisplayBody(getContext()));
|
||||
date.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getDateReceived()));
|
||||
|
||||
title.setVisibility(GONE);
|
||||
body.setVisibility(VISIBLE);
|
||||
date.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
@@ -162,10 +201,8 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
|
||||
icon.setColorFilter(getIconTintFilter());
|
||||
title.setText(ExpirationUtil.getExpirationDisplayValue(getContext(), (int)(messageRecord.getExpiresIn() / 1000)));
|
||||
body.setText(messageRecord.getDisplayBody(getContext()));
|
||||
|
||||
title.setVisibility(VISIBLE);
|
||||
body.setVisibility(VISIBLE);
|
||||
date.setVisibility(GONE);
|
||||
}
|
||||
|
||||
@@ -173,62 +210,56 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
return new PorterDuffColorFilter(ThemeUtil.getThemedColor(getContext(), R.attr.icon_tint), PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
private void setIdentityRecord(final MessageRecord messageRecord) {
|
||||
private void setIdentityRecord() {
|
||||
icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.safety_number_icon));
|
||||
icon.setColorFilter(getIconTintFilter());
|
||||
body.setText(messageRecord.getDisplayBody(getContext()));
|
||||
|
||||
title.setVisibility(GONE);
|
||||
body.setVisibility(VISIBLE);
|
||||
date.setVisibility(GONE);
|
||||
}
|
||||
|
||||
private void setIdentityVerifyUpdate(final MessageRecord messageRecord) {
|
||||
if (messageRecord.isIdentityVerified()) icon.setImageResource(R.drawable.ic_check_white_24dp);
|
||||
else icon.setImageResource(R.drawable.ic_info_outline_white_24dp);
|
||||
else icon.setImageResource(R.drawable.ic_info_outline_white_24);
|
||||
|
||||
icon.setColorFilter(getIconTintFilter());
|
||||
body.setText(messageRecord.getDisplayBody(getContext()));
|
||||
|
||||
title.setVisibility(GONE);
|
||||
body.setVisibility(VISIBLE);
|
||||
date.setVisibility(GONE);
|
||||
}
|
||||
|
||||
private void setGroupRecord(MessageRecord messageRecord) {
|
||||
private void setProfileNameChangeRecord() {
|
||||
icon.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_profile_outline_20));
|
||||
icon.setColorFilter(getIconTintFilter());
|
||||
|
||||
title.setVisibility(GONE);
|
||||
date.setVisibility(GONE);
|
||||
}
|
||||
|
||||
private void setGroupRecord() {
|
||||
icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.menu_group_icon));
|
||||
icon.clearColorFilter();
|
||||
|
||||
body.setText(messageRecord.getDisplayBody(getContext()));
|
||||
|
||||
title.setVisibility(GONE);
|
||||
body.setVisibility(VISIBLE);
|
||||
date.setVisibility(GONE);
|
||||
}
|
||||
|
||||
private void setJoinedRecord(MessageRecord messageRecord) {
|
||||
private void setJoinedRecord() {
|
||||
icon.setImageResource(R.drawable.ic_favorite_grey600_24dp);
|
||||
icon.clearColorFilter();
|
||||
body.setText(messageRecord.getDisplayBody(getContext()));
|
||||
|
||||
title.setVisibility(GONE);
|
||||
body.setVisibility(VISIBLE);
|
||||
date.setVisibility(GONE);
|
||||
}
|
||||
|
||||
private void setEndSessionRecord(MessageRecord messageRecord) {
|
||||
private void setEndSessionRecord() {
|
||||
icon.setImageResource(R.drawable.ic_refresh_white_24dp);
|
||||
icon.setColorFilter(getIconTintFilter());
|
||||
body.setText(messageRecord.getDisplayBody(getContext()));
|
||||
|
||||
title.setVisibility(GONE);
|
||||
body.setVisibility(VISIBLE);
|
||||
date.setVisibility(GONE);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
present(messageRecord);
|
||||
present(conversationMessage);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -241,9 +272,13 @@ public class ConversationUpdateItem extends LinearLayout
|
||||
if (sender != null) {
|
||||
sender.removeForeverObserver(this);
|
||||
}
|
||||
if (this.messageRecord != null && messageRecord.isGroupAction()) {
|
||||
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this);
|
||||
}
|
||||
|
||||
observeDisplayBody(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChanged(SpannableString update) {
|
||||
setBodyText(update);
|
||||
}
|
||||
|
||||
private class InternalClickListener implements View.OnClickListener {
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.MainThread;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
@@ -12,7 +13,6 @@ import androidx.paging.DataSource;
|
||||
import androidx.paging.LivePagedListBuilder;
|
||||
import androidx.paging.PagedList;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mediasend.Media;
|
||||
@@ -28,14 +28,16 @@ class ConversationViewModel extends ViewModel {
|
||||
|
||||
private static final String TAG = Log.tag(ConversationViewModel.class);
|
||||
|
||||
private final Application context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private final MutableLiveData<Long> threadId;
|
||||
private final LiveData<PagedList<MessageRecord>> messages;
|
||||
private final LiveData<ConversationData> conversationMetadata;
|
||||
private final Invalidator invalidator;
|
||||
private final Application context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private final MutableLiveData<Long> threadId;
|
||||
private final LiveData<PagedList<ConversationMessage>> messages;
|
||||
private final LiveData<ConversationData> conversationMetadata;
|
||||
private final Invalidator invalidator;
|
||||
private final MutableLiveData<Boolean> showScrollButtons;
|
||||
private final MutableLiveData<Boolean> hasUnreadMentions;
|
||||
|
||||
private int jumpToPosition;
|
||||
|
||||
@@ -46,6 +48,8 @@ class ConversationViewModel extends ViewModel {
|
||||
this.recentMedia = new MutableLiveData<>();
|
||||
this.threadId = new MutableLiveData<>();
|
||||
this.invalidator = new Invalidator();
|
||||
this.showScrollButtons = new MutableLiveData<>(false);
|
||||
this.hasUnreadMentions = new MutableLiveData<>(false);
|
||||
|
||||
LiveData<ConversationData> metadata = Transformations.switchMap(threadId, thread -> {
|
||||
LiveData<ConversationData> conversationData = conversationRepository.getConversationData(thread, jumpToPosition);
|
||||
@@ -55,12 +59,12 @@ class ConversationViewModel extends ViewModel {
|
||||
return conversationData;
|
||||
});
|
||||
|
||||
LiveData<Pair<Long, PagedList<MessageRecord>>> messagesForThreadId = Transformations.switchMap(metadata, data -> {
|
||||
DataSource.Factory<Integer, MessageRecord> factory = new ConversationDataSource.Factory(context, data.getThreadId(), invalidator);
|
||||
PagedList.Config config = new PagedList.Config.Builder()
|
||||
.setPageSize(25)
|
||||
.setInitialLoadSizeHint(25)
|
||||
.build();
|
||||
LiveData<Pair<Long, PagedList<ConversationMessage>>> messagesForThreadId = Transformations.switchMap(metadata, data -> {
|
||||
DataSource.Factory<Integer, ConversationMessage> factory = new ConversationDataSource.Factory(context, data.getThreadId(), invalidator);
|
||||
PagedList.Config config = new PagedList.Config.Builder()
|
||||
.setPageSize(25)
|
||||
.setInitialLoadSizeHint(25)
|
||||
.build();
|
||||
|
||||
final int startPosition;
|
||||
if (data.shouldJumpToMessage()) {
|
||||
@@ -94,6 +98,7 @@ class ConversationViewModel extends ViewModel {
|
||||
mediaRepository.getMediaInBucket(context, Media.ALL_MEDIA_BUCKET_ID, recentMedia::postValue);
|
||||
}
|
||||
|
||||
@MainThread
|
||||
void onConversationDataAvailable(long threadId, int startingPosition) {
|
||||
Log.d(TAG, "[onConversationDataAvailable] threadId: " + threadId + ", startingPosition: " + startingPosition);
|
||||
this.jumpToPosition = startingPosition;
|
||||
@@ -101,6 +106,27 @@ class ConversationViewModel extends ViewModel {
|
||||
this.threadId.setValue(threadId);
|
||||
}
|
||||
|
||||
void clearThreadId() {
|
||||
this.jumpToPosition = -1;
|
||||
this.threadId.postValue(-1L);
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> getShowScrollToBottom() {
|
||||
return Transformations.distinctUntilChanged(showScrollButtons);
|
||||
}
|
||||
|
||||
@NonNull LiveData<Boolean> getShowMentionsButton() {
|
||||
return Transformations.distinctUntilChanged(LiveDataUtil.combineLatest(showScrollButtons, hasUnreadMentions, (a, b) -> a && b));
|
||||
}
|
||||
|
||||
void setHasUnreadMentions(boolean hasUnreadMentions) {
|
||||
this.hasUnreadMentions.setValue(hasUnreadMentions);
|
||||
}
|
||||
|
||||
void setShowScrollButtons(boolean showScrollButtons) {
|
||||
this.showScrollButtons.setValue(showScrollButtons);
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<Media>> getRecentMedia() {
|
||||
return recentMedia;
|
||||
}
|
||||
@@ -109,7 +135,7 @@ class ConversationViewModel extends ViewModel {
|
||||
return conversationMetadata;
|
||||
}
|
||||
|
||||
@NonNull LiveData<PagedList<MessageRecord>> getMessages() {
|
||||
@NonNull LiveData<PagedList<ConversationMessage>> getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MessagingDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
class MarkReadHelper {
|
||||
private static final String TAG = Log.tag(MarkReadHelper.class);
|
||||
|
||||
private static final long DEBOUNCE_TIMEOUT = 100;
|
||||
private static final Executor EXECUTOR = new SerialMonoLifoExecutor(SignalExecutors.BOUNDED);
|
||||
|
||||
private final long threadId;
|
||||
private final Context context;
|
||||
private final Debouncer debouncer = new Debouncer(DEBOUNCE_TIMEOUT);
|
||||
private long latestTimestamp;
|
||||
|
||||
MarkReadHelper(long threadId, @NonNull Context context) {
|
||||
this.threadId = threadId;
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
public void onViewsRevealed(long timestamp) {
|
||||
if (timestamp <= latestTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
latestTimestamp = timestamp;
|
||||
|
||||
debouncer.publish(() -> {
|
||||
EXECUTOR.execute(() -> {
|
||||
ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(context);
|
||||
List<MessagingDatabase.MarkedMessageInfo> infos = threadDatabase.setReadSince(threadId, false, timestamp);
|
||||
|
||||
Log.d(TAG, "Marking " + infos.size() + " messages as read.");
|
||||
|
||||
ApplicationDependencies.getMessageNotifier().updateNotification(context);
|
||||
MarkReadReceiver.process(context, infos);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,8 @@ final class MenuState {
|
||||
messageRecord.isEndSession() ||
|
||||
messageRecord.isIdentityUpdate() ||
|
||||
messageRecord.isIdentityVerified() ||
|
||||
messageRecord.isIdentityDefault();
|
||||
messageRecord.isIdentityDefault() ||
|
||||
messageRecord.isProfileChange();
|
||||
}
|
||||
|
||||
private final static class Builder {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.app.Application;
|
||||
import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.whispersystems.libsignal.util.Pair;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
public class MessageCountsViewModel extends ViewModel {
|
||||
|
||||
private static final Executor EXECUTOR = new SerialMonoLifoExecutor(SignalExecutors.BOUNDED);
|
||||
|
||||
private final Application context;
|
||||
private final MutableLiveData<Long> threadId = new MutableLiveData<>(-1L);
|
||||
private final LiveData<Pair<Integer, Integer>> unreadCounts;
|
||||
|
||||
private ContentObserver observer;
|
||||
|
||||
public MessageCountsViewModel() {
|
||||
this.context = ApplicationDependencies.getApplication();
|
||||
this.unreadCounts = Transformations.switchMap(Transformations.distinctUntilChanged(threadId), id -> {
|
||||
|
||||
MutableLiveData<Pair<Integer, Integer>> counts = new MutableLiveData<>(new Pair<>(0, 0));
|
||||
|
||||
if (id == -1L) {
|
||||
return counts;
|
||||
}
|
||||
|
||||
observer = new ContentObserver(null) {
|
||||
@Override
|
||||
public void onChange(boolean selfChange) {
|
||||
EXECUTOR.execute(() -> {
|
||||
counts.postValue(getCounts(context, id));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
observer.onChange(false);
|
||||
|
||||
context.getContentResolver().registerContentObserver(DatabaseContentProviders.Conversation.getUriForThread(id), true, observer);
|
||||
|
||||
return counts;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
void setThreadId(long threadId) {
|
||||
this.threadId.setValue(threadId);
|
||||
}
|
||||
|
||||
void clearThreadId() {
|
||||
this.threadId.postValue(-1L);
|
||||
}
|
||||
|
||||
@NonNull LiveData<Integer> getUnreadMessagesCount() {
|
||||
return Transformations.map(unreadCounts, Pair::first);
|
||||
}
|
||||
|
||||
@NonNull LiveData<Integer> getUnreadMentionsCount() {
|
||||
return Transformations.map(unreadCounts, Pair::second);
|
||||
}
|
||||
|
||||
private Pair<Integer, Integer> getCounts(@NonNull Context context, long threadId) {
|
||||
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
|
||||
MmsDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(context);
|
||||
int unreadCount = mmsSmsDatabase.getUnreadCount(threadId);
|
||||
int unreadMentionCount = mmsDatabase.getUnreadMentionCount(threadId);
|
||||
|
||||
return new Pair<>(unreadCount, unreadMentionCount);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
if (observer != null) {
|
||||
context.getContentResolver().unregisterContentObserver(observer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import com.annimon.stream.Stream;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.VerifyIdentityActivity;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
@@ -35,6 +36,7 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
|
||||
private static final String RECIPIENT_IDS_EXTRA = "recipient_ids";
|
||||
private static final String MESSAGE_ID_EXTRA = "message_id";
|
||||
private static final String MESSAGE_TYPE_EXTRA = "message_type";
|
||||
|
||||
private SafetyNumberChangeViewModel viewModel;
|
||||
private SafetyNumberChangeAdapter adapter;
|
||||
@@ -42,6 +44,7 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
|
||||
public static @NonNull SafetyNumberChangeDialog create(List<IdentityDatabase.IdentityRecord> identityRecords) {
|
||||
List<String> ids = Stream.of(identityRecords)
|
||||
.filterNot(IdentityDatabase.IdentityRecord::isFirstUse)
|
||||
.map(record -> record.getRecipientId().serialize())
|
||||
.distinct()
|
||||
.toList();
|
||||
@@ -62,6 +65,7 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
Bundle arguments = new Bundle();
|
||||
arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0]));
|
||||
arguments.putLong(MESSAGE_ID_EXTRA, messageRecord.getId());
|
||||
arguments.putString(MESSAGE_TYPE_EXTRA, messageRecord.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT);
|
||||
SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog();
|
||||
fragment.setArguments(arguments);
|
||||
return fragment;
|
||||
@@ -78,10 +82,12 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
|
||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
|
||||
//noinspection ConstantConditions
|
||||
List<RecipientId> recipientIds = Stream.of(getArguments().getStringArray(RECIPIENT_IDS_EXTRA)).map(RecipientId::from).toList();
|
||||
long messageId = getArguments().getLong(MESSAGE_ID_EXTRA, -1);
|
||||
String messageType = getArguments().getString(MESSAGE_TYPE_EXTRA, null);
|
||||
|
||||
viewModel = ViewModelProviders.of(this, new SafetyNumberChangeViewModel.Factory(recipientIds, (messageId != -1) ? messageId : null)).get(SafetyNumberChangeViewModel.class);
|
||||
viewModel = ViewModelProviders.of(this, new SafetyNumberChangeViewModel.Factory(recipientIds, (messageId != -1) ? messageId : null, messageType)).get(SafetyNumberChangeViewModel.class);
|
||||
viewModel.getChangedRecipients().observe(getViewLifecycleOwner(), adapter::submitList);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,11 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
@@ -30,15 +33,17 @@ import static org.whispersystems.libsignal.SessionCipher.SESSION_LOCK;
|
||||
|
||||
final class SafetyNumberChangeRepository {
|
||||
|
||||
private static final String TAG = SafetyNumberChangeRepository.class.getSimpleName();
|
||||
|
||||
private final Context context;
|
||||
|
||||
SafetyNumberChangeRepository(Context context) {
|
||||
this.context = context.getApplicationContext();
|
||||
}
|
||||
|
||||
@NonNull LiveData<SafetyNumberChangeState> getSafetyNumberChangeState(@NonNull List<RecipientId> recipientIds, @Nullable Long messageId) {
|
||||
@NonNull LiveData<SafetyNumberChangeState> getSafetyNumberChangeState(@NonNull List<RecipientId> recipientIds, @Nullable Long messageId, @Nullable String messageType) {
|
||||
MutableLiveData<SafetyNumberChangeState> liveData = new MutableLiveData<>();
|
||||
SignalExecutors.BOUNDED.execute(() -> liveData.postValue(getSafetyNumberChangeStateInternal(recipientIds, messageId)));
|
||||
SignalExecutors.BOUNDED.execute(() -> liveData.postValue(getSafetyNumberChangeStateInternal(recipientIds, messageId, messageType)));
|
||||
return liveData;
|
||||
}
|
||||
|
||||
@@ -55,10 +60,10 @@ final class SafetyNumberChangeRepository {
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @NonNull SafetyNumberChangeState getSafetyNumberChangeStateInternal(@NonNull List<RecipientId> recipientIds, @Nullable Long messageId) {
|
||||
private @NonNull SafetyNumberChangeState getSafetyNumberChangeStateInternal(@NonNull List<RecipientId> recipientIds, @Nullable Long messageId, @Nullable String messageType) {
|
||||
MessageRecord messageRecord = null;
|
||||
if (messageId != null) {
|
||||
messageRecord = DatabaseFactory.getMmsSmsDatabase(context).getMessageRecord(messageId);
|
||||
if (messageId != null && messageType != null) {
|
||||
messageRecord = getMessageRecord(messageId, messageType);
|
||||
}
|
||||
|
||||
List<Recipient> recipients = Stream.of(recipientIds).map(Recipient::resolved).toList();
|
||||
@@ -70,6 +75,23 @@ final class SafetyNumberChangeRepository {
|
||||
return new SafetyNumberChangeState(changedRecipients, messageRecord);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private @Nullable MessageRecord getMessageRecord(Long messageId, String messageType) {
|
||||
try {
|
||||
switch (messageType) {
|
||||
case MmsSmsDatabase.SMS_TRANSPORT:
|
||||
return DatabaseFactory.getSmsDatabase(context).getMessageRecord(messageId);
|
||||
case MmsSmsDatabase.MMS_TRANSPORT:
|
||||
return DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId);
|
||||
default:
|
||||
throw new AssertionError("no valid message type specified");
|
||||
}
|
||||
} catch (NoSuchMessageException e) {
|
||||
Log.i(TAG, e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private TrustAndVerifyResult trustOrVerifyChangedRecipientsInternal(@NonNull List<ChangedRecipient> changedRecipients) {
|
||||
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
|
||||
|
||||
@@ -19,9 +19,13 @@ public final class SafetyNumberChangeViewModel extends ViewModel {
|
||||
private final SafetyNumberChangeRepository safetyNumberChangeRepository;
|
||||
private final LiveData<SafetyNumberChangeState> safetyNumberChangeState;
|
||||
|
||||
private SafetyNumberChangeViewModel(@NonNull List<RecipientId> recipientIds, @Nullable Long messageId, SafetyNumberChangeRepository safetyNumberChangeRepository) {
|
||||
private SafetyNumberChangeViewModel(@NonNull List<RecipientId> recipientIds,
|
||||
@Nullable Long messageId,
|
||||
@Nullable String messageType,
|
||||
SafetyNumberChangeRepository safetyNumberChangeRepository)
|
||||
{
|
||||
this.safetyNumberChangeRepository = safetyNumberChangeRepository;
|
||||
safetyNumberChangeState = this.safetyNumberChangeRepository.getSafetyNumberChangeState(recipientIds, messageId);
|
||||
safetyNumberChangeState = this.safetyNumberChangeRepository.getSafetyNumberChangeState(recipientIds, messageId, messageType);
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<ChangedRecipient>> getChangedRecipients() {
|
||||
@@ -40,16 +44,18 @@ public final class SafetyNumberChangeViewModel extends ViewModel {
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
private final List<RecipientId> recipientIds;
|
||||
private final Long messageId;
|
||||
private final String messageType;
|
||||
|
||||
public Factory(@NonNull List<RecipientId> recipientIds, @Nullable Long messageId) {
|
||||
public Factory(@NonNull List<RecipientId> recipientIds, @Nullable Long messageId, @Nullable String messageType) {
|
||||
this.recipientIds = recipientIds;
|
||||
this.messageId = messageId;
|
||||
this.messageType = messageType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository(ApplicationDependencies.getApplication());
|
||||
return Objects.requireNonNull(modelClass.cast(new SafetyNumberChangeViewModel(recipientIds, messageId, repo)));
|
||||
return Objects.requireNonNull(modelClass.cast(new SafetyNumberChangeViewModel(recipientIds, messageId, messageType, repo)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.thoughtcrime.securesms.conversation.ui.mentions;
|
||||
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder;
|
||||
|
||||
public class MentionViewHolder extends MappingViewHolder<MentionViewState> {
|
||||
|
||||
private final AvatarImageView avatar;
|
||||
private final TextView name;
|
||||
private final TextView username;
|
||||
|
||||
@Nullable private final MentionEventsListener mentionEventsListener;
|
||||
|
||||
public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) {
|
||||
super(itemView);
|
||||
this.mentionEventsListener = mentionEventsListener;
|
||||
|
||||
avatar = findViewById(R.id.mention_recipient_avatar);
|
||||
name = findViewById(R.id.mention_recipient_name);
|
||||
username = findViewById(R.id.mention_recipient_username);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bind(@NonNull MentionViewState model) {
|
||||
avatar.setRecipient(model.getRecipient());
|
||||
name.setText(model.getName(context));
|
||||
username.setText(model.getUsername());
|
||||
itemView.setOnClickListener(v -> {
|
||||
if (mentionEventsListener != null) {
|
||||
mentionEventsListener.onMentionClicked(model.getRecipient());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public interface MentionEventsListener {
|
||||
void onMentionClicked(@NonNull Recipient recipient);
|
||||
}
|
||||
|
||||
public static MappingAdapter.Factory<MentionViewState> createFactory(@Nullable MentionEventsListener mentionEventsListener) {
|
||||
return new MappingAdapter.LayoutFactory<>(view -> new MentionViewHolder(view, mentionEventsListener), R.layout.mentions_picker_recipient_list_item);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.thoughtcrime.securesms.conversation.ui.mentions;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public final class MentionViewState implements MappingModel<MentionViewState> {
|
||||
|
||||
private final Recipient recipient;
|
||||
|
||||
public MentionViewState(@NonNull Recipient recipient) {
|
||||
this.recipient = recipient;
|
||||
}
|
||||
|
||||
@NonNull String getName(@NonNull Context context) {
|
||||
return recipient.getDisplayName(context);
|
||||
}
|
||||
|
||||
@NonNull Recipient getRecipient() {
|
||||
return recipient;
|
||||
}
|
||||
|
||||
@NonNull String getUsername() {
|
||||
return Util.emptyIfNull(recipient.getDisplayUsername());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areItemsTheSame(@NonNull MentionViewState newItem) {
|
||||
return recipient.getId().equals(newItem.recipient.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean areContentsTheSame(@NonNull MentionViewState newItem) {
|
||||
Context context = ApplicationDependencies.getApplication();
|
||||
return recipient.getDisplayName(context).equals(newItem.recipient.getDisplayName(context)) &&
|
||||
Objects.equals(recipient.getProfileAvatar(), newItem.recipient.getProfileAvatar());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.conversation.ui.mentions;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionViewHolder.MentionEventsListener;
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class MentionsPickerAdapter extends MappingAdapter {
|
||||
private final Runnable currentListChangedListener;
|
||||
|
||||
public MentionsPickerAdapter(@Nullable MentionEventsListener mentionEventsListener, @NonNull Runnable currentListChangedListener) {
|
||||
this.currentListChangedListener = currentListChangedListener;
|
||||
registerFactory(MentionViewState.class, MentionViewHolder.createFactory(mentionEventsListener));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCurrentListChanged(@NonNull List<MappingModel<?>> previousList, @NonNull List<MappingModel<?>> currentList) {
|
||||
super.onCurrentListChanged(previousList, currentList);
|
||||
currentListChangedListener.run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package org.thoughtcrime.securesms.conversation.ui.mentions;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior;
|
||||
|
||||
import org.thoughtcrime.securesms.LoggingFragment;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.VibrateUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class MentionsPickerFragment extends LoggingFragment {
|
||||
|
||||
private MentionsPickerAdapter adapter;
|
||||
private RecyclerView list;
|
||||
private View topDivider;
|
||||
private View bottomDivider;
|
||||
private BottomSheetBehavior<View> behavior;
|
||||
private MentionsPickerViewModel viewModel;
|
||||
|
||||
@Override
|
||||
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
|
||||
View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false);
|
||||
|
||||
list = view.findViewById(R.id.mentions_picker_list);
|
||||
topDivider = view.findViewById(R.id.mentions_picker_top_divider);
|
||||
bottomDivider = view.findViewById(R.id.mentions_picker_bottom_divider);
|
||||
behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet));
|
||||
|
||||
initializeBehavior();
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
|
||||
super.onActivityCreated(savedInstanceState);
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(MentionsPickerViewModel.class);
|
||||
|
||||
initializeList();
|
||||
|
||||
viewModel.getMentionList().observe(getViewLifecycleOwner(), this::updateList);
|
||||
|
||||
viewModel.isShowing().observe(getViewLifecycleOwner(), isShowing -> {
|
||||
if (isShowing) {
|
||||
VibrateUtil.vibrateTick(requireContext());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeBehavior() {
|
||||
behavior.setState(BottomSheetBehavior.STATE_HIDDEN);
|
||||
|
||||
behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
|
||||
@Override
|
||||
public void onStateChanged(@NonNull View bottomSheet, int newState) {
|
||||
if (newState == BottomSheetBehavior.STATE_HIDDEN) {
|
||||
adapter.submitList(Collections.emptyList());
|
||||
} else {
|
||||
showDividers(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSlide(@NonNull View bottomSheet, float slideOffset) {
|
||||
showDividers(Float.isNaN(slideOffset) || slideOffset > -0.8f);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeList() {
|
||||
adapter = new MentionsPickerAdapter(this::handleMentionClicked, () -> updateBottomSheetBehavior(adapter.getItemCount()));
|
||||
|
||||
list.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||
list.setAdapter(adapter);
|
||||
list.setItemAnimator(null);
|
||||
}
|
||||
|
||||
private void handleMentionClicked(@NonNull Recipient recipient) {
|
||||
viewModel.onSelectionChange(recipient);
|
||||
}
|
||||
|
||||
private void updateList(@NonNull List<MappingModel<?>> mappingModels) {
|
||||
if (adapter.getItemCount() > 0 && mappingModels.isEmpty()) {
|
||||
updateBottomSheetBehavior(0);
|
||||
} else {
|
||||
adapter.submitList(mappingModels);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateBottomSheetBehavior(int count) {
|
||||
boolean isShowing = count > 0;
|
||||
|
||||
viewModel.setIsShowing(isShowing);
|
||||
|
||||
if (isShowing) {
|
||||
list.scrollToPosition(0);
|
||||
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
list.post(() -> behavior.setHideable(false));
|
||||
showDividers(true);
|
||||
} else {
|
||||
behavior.setHideable(true);
|
||||
behavior.setState(BottomSheetBehavior.STATE_HIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private void showDividers(boolean showDividers) {
|
||||
topDivider.setVisibility(showDividers ? View.VISIBLE : View.GONE);
|
||||
bottomDivider.setVisibility(showDividers ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.conversation.ui.mentions;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
final class MentionsPickerRepository {
|
||||
|
||||
private final RecipientDatabase recipientDatabase;
|
||||
|
||||
MentionsPickerRepository(@NonNull Context context) {
|
||||
recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@NonNull List<Recipient> search(@NonNull MentionQuery mentionQuery) {
|
||||
if (mentionQuery.query == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
List<RecipientId> recipientIds = Stream.of(mentionQuery.members)
|
||||
.filterNot(m -> m.getMember().isLocalNumber())
|
||||
.map(m -> m.getMember().getId())
|
||||
.toList();
|
||||
|
||||
return recipientDatabase.queryRecipientsForMentions(mentionQuery.query, recipientIds);
|
||||
}
|
||||
|
||||
static class MentionQuery {
|
||||
@Nullable private final String query;
|
||||
@NonNull private final List<GroupMemberEntry.FullMember> members;
|
||||
|
||||
MentionQuery(@Nullable String query, @NonNull List<GroupMemberEntry.FullMember> members) {
|
||||
this.query = query;
|
||||
this.members = members;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.thoughtcrime.securesms.conversation.ui.mentions;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerRepository.MentionQuery;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.LiveGroup;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry.FullMember;
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.MappingModel;
|
||||
import org.thoughtcrime.securesms.util.SingleLiveEvent;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
public class MentionsPickerViewModel extends ViewModel {
|
||||
|
||||
private final SingleLiveEvent<Recipient> selectedRecipient;
|
||||
private final LiveData<List<MappingModel<?>>> mentionList;
|
||||
private final MutableLiveData<LiveGroup> group;
|
||||
private final MutableLiveData<Query> liveQuery;
|
||||
private final MutableLiveData<Boolean> isShowing;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
|
||||
MentionsPickerViewModel(@NonNull MentionsPickerRepository mentionsPickerRepository, @NonNull MegaphoneRepository megaphoneRepository) {
|
||||
this.megaphoneRepository = megaphoneRepository;
|
||||
|
||||
group = new MutableLiveData<>();
|
||||
liveQuery = new MutableLiveData<>(Query.NONE);
|
||||
selectedRecipient = new SingleLiveEvent<>();
|
||||
isShowing = new MutableLiveData<>(false);
|
||||
|
||||
LiveData<List<FullMember>> fullMembers = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers));
|
||||
LiveData<Query> query = Transformations.distinctUntilChanged(liveQuery);
|
||||
LiveData<MentionQuery> mentionQuery = LiveDataUtil.combineLatest(query, fullMembers, (q, m) -> new MentionQuery(q.query, m));
|
||||
|
||||
mentionList = LiveDataUtil.mapAsync(mentionQuery, q -> Stream.of(mentionsPickerRepository.search(q)).<MappingModel<?>>map(MentionViewState::new).toList());
|
||||
}
|
||||
|
||||
@NonNull LiveData<List<MappingModel<?>>> getMentionList() {
|
||||
return mentionList;
|
||||
}
|
||||
|
||||
void onSelectionChange(@NonNull Recipient recipient) {
|
||||
selectedRecipient.setValue(recipient);
|
||||
megaphoneRepository.markFinished(Megaphones.Event.MENTIONS);
|
||||
}
|
||||
|
||||
void setIsShowing(boolean isShowing) {
|
||||
if (Objects.equals(this.isShowing.getValue(), isShowing)) {
|
||||
return;
|
||||
}
|
||||
this.isShowing.setValue(isShowing);
|
||||
}
|
||||
|
||||
public @NonNull LiveData<Recipient> getSelectedRecipient() {
|
||||
return selectedRecipient;
|
||||
}
|
||||
|
||||
public @NonNull LiveData<Boolean> isShowing() {
|
||||
return isShowing;
|
||||
}
|
||||
|
||||
public void onQueryChange(@Nullable String query) {
|
||||
liveQuery.setValue(query == null ? Query.NONE : new Query(query));
|
||||
}
|
||||
|
||||
public void onRecipientChange(@NonNull Recipient recipient) {
|
||||
GroupId groupId = recipient.getGroupId().orNull();
|
||||
if (groupId != null) {
|
||||
LiveGroup liveGroup = new LiveGroup(groupId);
|
||||
group.setValue(liveGroup);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a nullable query string so it can be properly propagated through
|
||||
* {@link LiveDataUtil#combineLatest(LiveData, LiveData, LiveDataUtil.Combine)}.
|
||||
*/
|
||||
private static class Query {
|
||||
static final Query NONE = new Query(null);
|
||||
|
||||
@Nullable private final String query;
|
||||
|
||||
Query(@Nullable String query) {
|
||||
this.query = query;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object object) {
|
||||
if (this == object) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (object == null || getClass() != object.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Query other = (Query) object;
|
||||
return Objects.equals(query, other.query);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(query);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Factory implements ViewModelProvider.Factory {
|
||||
@Override
|
||||
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
|
||||
//noinspection ConstantConditions
|
||||
return modelClass.cast(new MentionsPickerViewModel(new MentionsPickerRepository(ApplicationDependencies.getApplication()),
|
||||
ApplicationDependencies.getMegaphoneRepository()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.paging.PagedListAdapter;
|
||||
@@ -13,9 +14,7 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import org.thoughtcrime.securesms.BindableConversationListItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
@@ -34,6 +33,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
private static final int TYPE_THREAD = 1;
|
||||
private static final int TYPE_ACTION = 2;
|
||||
private static final int TYPE_PLACEHOLDER = 3;
|
||||
private static final int TYPE_HEADER = 4;
|
||||
|
||||
private enum Payload {
|
||||
TYPING_INDICATOR,
|
||||
@@ -45,9 +45,10 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
private final Map<Long, Conversation> batchSet = Collections.synchronizedMap(new HashMap<>());
|
||||
private boolean batchMode = false;
|
||||
private final Set<Long> typingSet = new HashSet<>();
|
||||
private int archived;
|
||||
|
||||
protected ConversationListAdapter(@NonNull GlideRequests glideRequests, @NonNull OnConversationClickListener onConversationClickListener) {
|
||||
protected ConversationListAdapter(@NonNull GlideRequests glideRequests,
|
||||
@NonNull OnConversationClickListener onConversationClickListener)
|
||||
{
|
||||
super(new ConversationDiffCallback());
|
||||
|
||||
this.glideRequests = glideRequests;
|
||||
@@ -61,9 +62,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
.inflate(R.layout.conversation_list_item_action, parent, false));
|
||||
|
||||
holder.itemView.setOnClickListener(v -> {
|
||||
int position = holder.getAdapterPosition();
|
||||
|
||||
if (position != RecyclerView.NO_POSITION) {
|
||||
if (holder.getAdapterPosition() != RecyclerView.NO_POSITION) {
|
||||
onConversationClickListener.onShowArchiveClick();
|
||||
}
|
||||
});
|
||||
@@ -95,6 +94,9 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
View v = new FrameLayout(parent.getContext());
|
||||
v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100)));
|
||||
return new PlaceholderViewHolder(v);
|
||||
} else if (viewType == TYPE_HEADER) {
|
||||
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_header, parent, false);
|
||||
return new HeaderViewHolder(v);
|
||||
} else {
|
||||
throw new IllegalStateException("Unknown type! " + viewType);
|
||||
}
|
||||
@@ -104,7 +106,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
|
||||
if (payloads.isEmpty()) {
|
||||
onBindViewHolder(holder, position);
|
||||
} else {
|
||||
} else if (holder instanceof ConversationViewHolder) {
|
||||
for (Object payloadObject : payloads) {
|
||||
if (payloadObject instanceof Payload) {
|
||||
Payload payload = (Payload) payloadObject;
|
||||
@@ -121,21 +123,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
if (holder.getItemViewType() == TYPE_ACTION) {
|
||||
ConversationViewHolder casted = (ConversationViewHolder) holder;
|
||||
|
||||
casted.getConversationListItem().bind(new ThreadRecord.Builder(100)
|
||||
.setBody("")
|
||||
.setDate(100)
|
||||
.setRecipient(Recipient.UNKNOWN)
|
||||
.setCount(archived)
|
||||
.build(),
|
||||
glideRequests,
|
||||
Locale.getDefault(),
|
||||
typingSet,
|
||||
getBatchSelectionIds(),
|
||||
batchMode);
|
||||
} else if (holder.getItemViewType() == TYPE_THREAD) {
|
||||
if (holder.getItemViewType() == TYPE_ACTION || holder.getItemViewType() == TYPE_THREAD) {
|
||||
ConversationViewHolder casted = (ConversationViewHolder) holder;
|
||||
Conversation conversation = Objects.requireNonNull(getItem(position));
|
||||
|
||||
@@ -145,6 +133,19 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
typingSet,
|
||||
getBatchSelectionIds(),
|
||||
batchMode);
|
||||
} else if (holder.getItemViewType() == TYPE_HEADER) {
|
||||
HeaderViewHolder casted = (HeaderViewHolder) holder;
|
||||
Conversation conversation = Objects.requireNonNull(getItem(position));
|
||||
switch (conversation.getType()) {
|
||||
case PINNED_HEADER:
|
||||
casted.headerText.setText(R.string.conversation_list__pinned);
|
||||
break;
|
||||
case UNPINNED_HEADER:
|
||||
casted.headerText.setText(R.string.conversation_list__chats);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,35 +177,22 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
return batchSet.values();
|
||||
}
|
||||
|
||||
void updateArchived(int archived) {
|
||||
int oldArchived = this.archived;
|
||||
|
||||
this.archived = archived;
|
||||
|
||||
if (oldArchived != archived) {
|
||||
if (archived == 0) {
|
||||
notifyItemRemoved(getItemCount());
|
||||
} else if (oldArchived == 0) {
|
||||
notifyItemInserted(getItemCount() - 1);
|
||||
} else {
|
||||
notifyItemChanged(getItemCount() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return (archived > 0 ? 1 : 0) + super.getItemCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemViewType(int position) {
|
||||
if (archived > 0 && position == getItemCount() - 1) {
|
||||
return TYPE_ACTION;
|
||||
} else if (getItem(position) == null) {
|
||||
Conversation conversation = getItem(position);
|
||||
if (conversation == null) {
|
||||
return TYPE_PLACEHOLDER;
|
||||
} else {
|
||||
return TYPE_THREAD;
|
||||
}
|
||||
switch (conversation.getType()) {
|
||||
case PINNED_HEADER:
|
||||
case UNPINNED_HEADER:
|
||||
return TYPE_HEADER;
|
||||
case ARCHIVED_FOOTER:
|
||||
return TYPE_ACTION;
|
||||
case THREAD:
|
||||
return TYPE_THREAD;
|
||||
default:
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +203,7 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
void selectAllThreads() {
|
||||
for (int i = 0; i < super.getItemCount(); i++) {
|
||||
Conversation conversation = getItem(i);
|
||||
if (conversation != null && conversation.getThreadRecord().getThreadId() != -1) {
|
||||
if (conversation != null && conversation.getThreadRecord().getThreadId() >= 0) {
|
||||
batchSet.put(conversation.getThreadRecord().getThreadId(), conversation);
|
||||
}
|
||||
}
|
||||
@@ -268,6 +256,15 @@ class ConversationListAdapter extends PagedListAdapter<Conversation, RecyclerVie
|
||||
}
|
||||
}
|
||||
|
||||
static class HeaderViewHolder extends RecyclerView.ViewHolder {
|
||||
private TextView headerText;
|
||||
|
||||
public HeaderViewHolder(@NonNull View itemView) {
|
||||
super(itemView);
|
||||
headerText = (TextView) itemView;
|
||||
}
|
||||
}
|
||||
|
||||
interface OnConversationClickListener {
|
||||
void onConversationClick(Conversation conversation);
|
||||
boolean onConversationLongClick(Conversation conversation);
|
||||
|
||||
@@ -3,23 +3,30 @@ package org.thoughtcrime.securesms.conversationlist;
|
||||
import android.content.Context;
|
||||
import android.database.ContentObserver;
|
||||
import android.database.Cursor;
|
||||
import android.database.MatrixCursor;
|
||||
import android.database.MergeCursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.paging.DataSource;
|
||||
import androidx.paging.PositionalDataSource;
|
||||
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationReader;
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
import org.thoughtcrime.securesms.util.paging.SizeFixResult;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
@@ -66,22 +73,26 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
|
||||
List<Conversation> conversations = new ArrayList<>(params.requestedLoadSize);
|
||||
int totalCount = getTotalCount();
|
||||
int effectiveCount = params.requestedStartPosition;
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(getCursor(params.requestedStartPosition, params.requestedLoadSize))) {
|
||||
try (ConversationReader reader = new ConversationReader(getCursor(params.requestedStartPosition, params.requestedLoadSize))) {
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
|
||||
conversations.add(new Conversation(record));
|
||||
recipients.add(record.getRecipient());
|
||||
effectiveCount++;
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getRecipientCache().addToCache(recipients);
|
||||
|
||||
if (!isInvalid()) {
|
||||
SizeFixResult<Conversation> result = SizeFixResult.ensureMultipleOfPageSize(conversations, params.requestedStartPosition, params.pageSize, totalCount);
|
||||
|
||||
callback.onResult(result.getItems(), params.requestedStartPosition, result.getTotal());
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", actualSize: " + result.getItems().size() + ", totalCount: " + result.getTotal() + ", class: " + getClass().getSimpleName());
|
||||
} else {
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", totalCount: " + totalCount + ", class: " + getClass().getSimpleName() + " -- invalidated");
|
||||
}
|
||||
|
||||
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -89,17 +100,21 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
List<Conversation> conversations = new ArrayList<>(params.loadSize);
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
try (ThreadDatabase.Reader reader = threadDatabase.readerFor(getCursor(params.startPosition, params.loadSize))) {
|
||||
try (ConversationReader reader = new ConversationReader(getCursor(params.startPosition, params.loadSize))) {
|
||||
ThreadRecord record;
|
||||
while ((record = reader.getNext()) != null && !isInvalid()) {
|
||||
conversations.add(new Conversation(record));
|
||||
recipients.add(record.getRecipient());
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getRecipientCache().addToCache(recipients);
|
||||
|
||||
callback.onResult(conversations);
|
||||
|
||||
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : ""));
|
||||
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms | start: " + params.startPosition + ", size: " + params.loadSize + ", class: " + getClass().getSimpleName() + (isInvalid() ? " -- invalidated" : ""));
|
||||
}
|
||||
|
||||
protected abstract int getTotalCount();
|
||||
@@ -122,7 +137,13 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
|
||||
}
|
||||
}
|
||||
|
||||
private static class UnarchivedConversationListDataSource extends ConversationListDataSource {
|
||||
@VisibleForTesting
|
||||
static class UnarchivedConversationListDataSource extends ConversationListDataSource {
|
||||
|
||||
private int totalCount;
|
||||
private int pinnedCount;
|
||||
private int archivedCount;
|
||||
private int unpinnedCount;
|
||||
|
||||
UnarchivedConversationListDataSource(@NonNull Context context, @NonNull Invalidator invalidator) {
|
||||
super(context, invalidator);
|
||||
@@ -130,12 +151,69 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
|
||||
|
||||
@Override
|
||||
protected int getTotalCount() {
|
||||
return threadDatabase.getUnarchivedConversationListCount();
|
||||
int unarchivedCount = threadDatabase.getUnarchivedConversationListCount();
|
||||
|
||||
pinnedCount = threadDatabase.getPinnedConversationListCount();
|
||||
archivedCount = threadDatabase.getArchivedConversationListCount();
|
||||
unpinnedCount = unarchivedCount - pinnedCount;
|
||||
totalCount = unarchivedCount + (archivedCount != 0 ? 1 : 0) + (pinnedCount != 0 ? (unpinnedCount != 0 ? 2 : 1) : 0);
|
||||
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Cursor getCursor(long offset, long limit) {
|
||||
return threadDatabase.getConversationList(offset, limit);
|
||||
List<Cursor> cursors = new ArrayList<>(5);
|
||||
|
||||
if (offset == 0 && hasPinnedHeader()) {
|
||||
MatrixCursor pinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
|
||||
pinnedHeaderCursor.addRow(ConversationReader.PINNED_HEADER);
|
||||
cursors.add(pinnedHeaderCursor);
|
||||
limit--;
|
||||
}
|
||||
|
||||
Cursor pinnedCursor = threadDatabase.getUnarchivedConversationList(true, offset, limit);
|
||||
cursors.add(pinnedCursor);
|
||||
limit -= pinnedCursor.getCount();
|
||||
|
||||
if (offset == 0 && hasUnpinnedHeader()) {
|
||||
MatrixCursor unpinnedHeaderCursor = new MatrixCursor(ConversationReader.HEADER_COLUMN);
|
||||
unpinnedHeaderCursor.addRow(ConversationReader.UNPINNED_HEADER);
|
||||
cursors.add(unpinnedHeaderCursor);
|
||||
limit--;
|
||||
}
|
||||
|
||||
long unpinnedOffset = Math.max(0, offset - pinnedCount - getHeaderOffset());
|
||||
Cursor unpinnedCursor = threadDatabase.getUnarchivedConversationList(false, unpinnedOffset, limit);
|
||||
cursors.add(unpinnedCursor);
|
||||
|
||||
if (offset + limit >= totalCount && hasArchivedFooter()) {
|
||||
MatrixCursor archivedFooterCursor = new MatrixCursor(ConversationReader.ARCHIVED_COLUMNS);
|
||||
archivedFooterCursor.addRow(ConversationReader.createArchivedFooterRow(archivedCount));
|
||||
cursors.add(archivedFooterCursor);
|
||||
}
|
||||
|
||||
return new MergeCursor(cursors.toArray(new Cursor[]{}));
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
int getHeaderOffset() {
|
||||
return (hasPinnedHeader() ? 1 : 0) + (hasUnpinnedHeader() ? 1 : 0);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasPinnedHeader() {
|
||||
return pinnedCount != 0;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasUnpinnedHeader() {
|
||||
return hasPinnedHeader() && unpinnedCount != 0;
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
boolean hasArchivedFooter() {
|
||||
return archivedCount != 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ import androidx.lifecycle.DefaultLifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.ProcessLifecycleOwner;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.paging.PagedList;
|
||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
@@ -88,6 +89,7 @@ import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.ShareReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.SystemSmsImportReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationFragment;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
|
||||
@@ -145,6 +147,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
private static final String TAG = Log.tag(ConversationListFragment.class);
|
||||
|
||||
private static final int MAXIMUM_PINNED_CONVERSATIONS = 4;
|
||||
|
||||
private static final int[] EMPTY_IMAGES = new int[] { R.drawable.empty_inbox_1,
|
||||
R.drawable.empty_inbox_2,
|
||||
R.drawable.empty_inbox_3,
|
||||
@@ -209,7 +213,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
reminderView.setOnDismissListener(this::updateReminders);
|
||||
|
||||
list.setHasFixedSize(true);
|
||||
list.setLayoutManager(new LinearLayoutManager(requireActivity()));
|
||||
list.setItemAnimator(new DeleteItemAnimator());
|
||||
list.addOnScrollListener(new ScrollListener());
|
||||
@@ -266,8 +269,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
@Override
|
||||
public void onStart() {
|
||||
super.onStart();
|
||||
// TODO [greyson] Re-enable when we figure out how to invalidate the cache after a system theme change
|
||||
// ConversationFragment.prepare(requireContext());
|
||||
ConversationFragment.prepare(requireContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -279,7 +281,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onPrepareOptionsMenu(Menu menu) {
|
||||
MenuInflater inflater = requireActivity().getMenuInflater();
|
||||
@@ -330,7 +331,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
if (requestCode == CreateKbsPinActivity.REQUEST_NEW_PIN) {
|
||||
Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).setTextColor(Color.WHITE).show();
|
||||
viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL);
|
||||
}
|
||||
}
|
||||
@@ -388,7 +389,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
@Override
|
||||
public void onMegaphoneToastRequested(@NonNull String string) {
|
||||
Snackbar.make(fab, string, Snackbar.LENGTH_LONG).show();
|
||||
Snackbar.make(fab, string, Snackbar.LENGTH_LONG)
|
||||
.setTextColor(Color.WHITE)
|
||||
.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -497,9 +500,10 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private void initializeViewModel() {
|
||||
viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class);
|
||||
|
||||
viewModel.getSearchResult().observe(this, this::onSearchResultChanged);
|
||||
viewModel.getMegaphone().observe(this, this::onMegaphoneChanged);
|
||||
viewModel.getConversationList().observe(this, this::onSubmitList);
|
||||
viewModel.getSearchResult().observe(getViewLifecycleOwner(), this::onSearchResultChanged);
|
||||
viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged);
|
||||
viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onSubmitList);
|
||||
viewModel.hasNoConversations().observe(getViewLifecycleOwner(), this::updateEmptyState);
|
||||
|
||||
ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
|
||||
@Override
|
||||
@@ -737,6 +741,51 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
alert.show();
|
||||
}
|
||||
|
||||
private void handlePinAllSelected() {
|
||||
final Set<Long> toPin = new HashSet<>(Stream.of(defaultAdapter.getBatchSelection())
|
||||
.filterNot(conversation -> conversation.getThreadRecord().isPinned())
|
||||
.map(conversation -> conversation.getThreadRecord().getThreadId())
|
||||
.toList());
|
||||
|
||||
if (toPin.size() + viewModel.getPinnedCount() > MAXIMUM_PINNED_CONVERSATIONS) {
|
||||
Snackbar.make(fab,
|
||||
getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS),
|
||||
Snackbar.LENGTH_LONG)
|
||||
.setTextColor(Color.WHITE)
|
||||
.show();
|
||||
actionMode.finish();
|
||||
return;
|
||||
}
|
||||
|
||||
SimpleTask.run(SignalExecutors.BOUNDED, () -> {
|
||||
ThreadDatabase db = DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication());
|
||||
|
||||
db.pinConversations(toPin);
|
||||
|
||||
return null;
|
||||
}, unused -> {
|
||||
if (actionMode != null) {
|
||||
actionMode.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleUnpinAllSelected() {
|
||||
final Set<Long> toPin = new HashSet<>(defaultAdapter.getBatchSelectionIds());
|
||||
|
||||
SimpleTask.run(SignalExecutors.BOUNDED, () -> {
|
||||
ThreadDatabase db = DatabaseFactory.getThreadDatabase(ApplicationDependencies.getApplication());
|
||||
|
||||
db.unpinConversations(toPin);
|
||||
|
||||
return null;
|
||||
}, unused -> {
|
||||
if (actionMode != null) {
|
||||
actionMode.finish();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void handleSelectAllThreads() {
|
||||
defaultAdapter.selectAllThreads();
|
||||
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
|
||||
@@ -747,7 +796,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
private void onSubmitList(@NonNull ConversationListViewModel.ConversationList conversationList) {
|
||||
if (conversationList.isEmpty()) {
|
||||
defaultAdapter.submitList(conversationList.getConversations());
|
||||
|
||||
onPostSubmitList();
|
||||
}
|
||||
|
||||
private void updateEmptyState(boolean isConversationEmpty) {
|
||||
if (isConversationEmpty) {
|
||||
Log.i(TAG, "Received an empty data set.");
|
||||
list.setVisibility(View.INVISIBLE);
|
||||
emptyState.setVisibility(View.VISIBLE);
|
||||
emptyImage.setImageResource(EMPTY_IMAGES[(int) (Math.random() * EMPTY_IMAGES.length)]);
|
||||
@@ -759,11 +815,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
fab.stopPulse();
|
||||
cameraFab.stopPulse();
|
||||
}
|
||||
|
||||
defaultAdapter.submitList(conversationList.getConversations());
|
||||
defaultAdapter.updateArchived(conversationList.getArchivedCount());
|
||||
|
||||
onPostSubmitList();
|
||||
}
|
||||
|
||||
protected void onPostSubmitList() {
|
||||
@@ -787,11 +838,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
@Override
|
||||
public boolean onConversationLongClick(Conversation conversation) {
|
||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
|
||||
|
||||
defaultAdapter.initializeBatchMode(true);
|
||||
defaultAdapter.toggleConversationInBatchSet(conversation);
|
||||
|
||||
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -799,6 +850,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
|
||||
MenuInflater inflater = getActivity().getMenuInflater();
|
||||
|
||||
inflater.inflate(R.menu.conversation_list_batch_pin, menu);
|
||||
inflater.inflate(getActionModeMenuRes(), menu);
|
||||
inflater.inflate(R.menu.conversation_list_batch, menu);
|
||||
|
||||
@@ -827,6 +879,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_select_all: handleSelectAllThreads(); return true;
|
||||
case R.id.menu_delete_selected: handleDeleteAllSelected(); return true;
|
||||
case R.id.menu_pin_selected: handlePinAllSelected(); return true;
|
||||
case R.id.menu_unpin_selected: handleUnpinAllSelected(); return true;
|
||||
case R.id.menu_archive_selected: handleArchiveAllSelected(); return true;
|
||||
case R.id.menu_mark_as_read: handleMarkSelectedAsRead(); return true;
|
||||
case R.id.menu_mark_as_unread: handleMarkSelectedAsUnread(); return true;
|
||||
@@ -871,7 +925,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
private void setCorrectMenuVisibility(@NonNull Menu menu) {
|
||||
boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead());
|
||||
boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isRead());
|
||||
boolean hasUnpinned = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(conversation -> !conversation.getThreadRecord().isPinned());
|
||||
boolean canPin = viewModel.getPinnedCount() < MAXIMUM_PINNED_CONVERSATIONS;
|
||||
|
||||
if (hasUnread) {
|
||||
menu.findItem(R.id.menu_mark_as_unread).setVisible(false);
|
||||
@@ -880,6 +936,17 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
menu.findItem(R.id.menu_mark_as_unread).setVisible(true);
|
||||
menu.findItem(R.id.menu_mark_as_read).setVisible(false);
|
||||
}
|
||||
|
||||
if (!isArchived() && hasUnpinned && canPin) {
|
||||
menu.findItem(R.id.menu_pin_selected).setVisible(true);
|
||||
menu.findItem(R.id.menu_unpin_selected).setVisible(false);
|
||||
} else if (!isArchived() && !hasUnpinned) {
|
||||
menu.findItem(R.id.menu_pin_selected).setVisible(false);
|
||||
menu.findItem(R.id.menu_unpin_selected).setVisible(true);
|
||||
} else {
|
||||
menu.findItem(R.id.menu_pin_selected).setVisible(false);
|
||||
menu.findItem(R.id.menu_unpin_selected).setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected @IdRes int getToolbarRes() {
|
||||
@@ -955,8 +1022,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
@Override
|
||||
public int getSwipeDirs(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
|
||||
if (viewHolder.itemView instanceof ConversationListItemAction ||
|
||||
actionMode != null ||
|
||||
if (viewHolder.itemView instanceof ConversationListItemAction ||
|
||||
viewHolder instanceof ConversationListAdapter.HeaderViewHolder ||
|
||||
actionMode != null ||
|
||||
activeAdapter == searchAdapter)
|
||||
{
|
||||
return 0;
|
||||
|
||||
@@ -21,7 +21,6 @@ import android.content.res.ColorStateList;
|
||||
import android.graphics.Typeface;
|
||||
import android.graphics.drawable.RippleDrawable;
|
||||
import android.os.Build.VERSION;
|
||||
import android.os.Build.VERSION_CODES;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.style.StyleSpan;
|
||||
@@ -32,6 +31,9 @@ import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
import org.thoughtcrime.securesms.BindableConversationListItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
@@ -42,43 +44,49 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
|
||||
import org.thoughtcrime.securesms.components.FromTextView;
|
||||
import org.thoughtcrime.securesms.components.ThumbnailView;
|
||||
import org.thoughtcrime.securesms.components.TypingIndicatorView;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.database.MmsSmsColumns;
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.database.model.UpdateDescription;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.SearchUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
public class ConversationListItem extends RelativeLayout
|
||||
implements RecipientForeverObserver,
|
||||
BindableConversationListItem, Unbindable
|
||||
import static org.thoughtcrime.securesms.database.model.LiveUpdateMessage.recipientToStringAsync;
|
||||
|
||||
public final class ConversationListItem extends RelativeLayout
|
||||
implements RecipientForeverObserver,
|
||||
BindableConversationListItem,
|
||||
Unbindable,
|
||||
Observer<SpannableString>
|
||||
{
|
||||
@SuppressWarnings("unused")
|
||||
private final static String TAG = ConversationListItem.class.getSimpleName();
|
||||
private final static String TAG = Log.tag(ConversationListItem.class);
|
||||
|
||||
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
|
||||
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
|
||||
|
||||
private static final int MAX_SNIPPET_LENGTH = 500;
|
||||
|
||||
private Set<Long> selectedThreads;
|
||||
private Set<Long> typingThreads;
|
||||
private LiveRecipient recipient;
|
||||
private LiveRecipient groupAddedBy;
|
||||
private long threadId;
|
||||
private GlideRequests glideRequests;
|
||||
private View subjectContainer;
|
||||
@@ -98,13 +106,9 @@ public class ConversationListItem extends RelativeLayout
|
||||
private AvatarImageView contactPhotoImage;
|
||||
private ThumbnailView thumbnailView;
|
||||
|
||||
private int distributionType;
|
||||
private final Debouncer subjectViewClearDebouncer = new Debouncer(150);
|
||||
|
||||
private final RecipientForeverObserver groupAddedByObserver = adder -> {
|
||||
if (isAttachedToWindow() && subjectView != null && thread != null) {
|
||||
subjectView.setText(getThreadDisplayBody(getContext(), thread));
|
||||
}
|
||||
};
|
||||
private LiveData<SpannableString> displayBody;
|
||||
|
||||
public ConversationListItem(Context context) {
|
||||
this(context, null);
|
||||
@@ -154,16 +158,16 @@ public class ConversationListItem extends RelativeLayout
|
||||
@Nullable String highlightSubstring)
|
||||
{
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
|
||||
observeDisplayBody(null);
|
||||
setSubjectViewText(null);
|
||||
|
||||
this.selectedThreads = selectedThreads;
|
||||
this.recipient = thread.getRecipient().live();
|
||||
this.threadId = thread.getThreadId();
|
||||
this.glideRequests = glideRequests;
|
||||
this.unreadCount = thread.getUnreadCount();
|
||||
this.distributionType = thread.getDistributionType();
|
||||
this.lastSeen = thread.getLastSeen();
|
||||
this.thread = thread;
|
||||
this.selectedThreads = selectedThreads;
|
||||
this.recipient = thread.getRecipient().live();
|
||||
this.threadId = thread.getThreadId();
|
||||
this.glideRequests = glideRequests;
|
||||
this.unreadCount = thread.getUnreadCount();
|
||||
this.lastSeen = thread.getLastSeen();
|
||||
this.thread = thread;
|
||||
|
||||
this.recipient.observeForever(this);
|
||||
if (highlightSubstring != null) {
|
||||
@@ -174,14 +178,10 @@ public class ConversationListItem extends RelativeLayout
|
||||
this.fromView.setText(recipient.get(), thread.isRead());
|
||||
}
|
||||
|
||||
this.typingThreads = typingThreads;
|
||||
updateTypingIndicator(typingThreads);
|
||||
|
||||
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread)));
|
||||
|
||||
if (thread.getGroupAddedBy() != null) {
|
||||
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
|
||||
groupAddedBy.observeForever(groupAddedByObserver);
|
||||
}
|
||||
observeDisplayBody(getThreadDisplayBody(getContext(), thread));
|
||||
|
||||
this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
|
||||
this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
|
||||
@@ -215,7 +215,8 @@ public class ConversationListItem extends RelativeLayout
|
||||
@Nullable String highlightSubstring)
|
||||
{
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
|
||||
observeDisplayBody(null);
|
||||
setSubjectViewText(null);
|
||||
|
||||
this.selectedThreads = Collections.emptySet();
|
||||
this.recipient = contact.live();
|
||||
@@ -225,7 +226,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
|
||||
fromView.setText(contact);
|
||||
fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), new SpannableString(fromView.getText()), highlightSubstring));
|
||||
subjectView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), contact.getE164().or(""), highlightSubstring));
|
||||
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), contact.getE164().or(""), highlightSubstring));
|
||||
dateView.setText("");
|
||||
archivedView.setVisibility(GONE);
|
||||
unreadIndicator.setVisibility(GONE);
|
||||
@@ -244,7 +245,8 @@ public class ConversationListItem extends RelativeLayout
|
||||
@Nullable String highlightSubstring)
|
||||
{
|
||||
if (this.recipient != null) this.recipient.removeForeverObserver(this);
|
||||
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
|
||||
observeDisplayBody(null);
|
||||
setSubjectViewText(null);
|
||||
|
||||
this.selectedThreads = Collections.emptySet();
|
||||
this.recipient = messageResult.conversationRecipient.live();
|
||||
@@ -253,7 +255,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
this.recipient.observeForever(this);
|
||||
|
||||
fromView.setText(recipient.get(), true);
|
||||
subjectView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), messageResult.bodySnippet, highlightSubstring));
|
||||
setSubjectViewText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), messageResult.bodySnippet, highlightSubstring));
|
||||
dateView.setText(DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, messageResult.receivedTimestampMs));
|
||||
archivedView.setVisibility(GONE);
|
||||
unreadIndicator.setVisibility(GONE);
|
||||
@@ -276,10 +278,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
contactPhotoImage.setAvatar(glideRequests, null, !batchMode);
|
||||
}
|
||||
|
||||
if (this.groupAddedBy != null) {
|
||||
this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
|
||||
this.groupAddedBy = null;
|
||||
}
|
||||
observeDisplayBody(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -319,17 +318,30 @@ public class ConversationListItem extends RelativeLayout
|
||||
return unreadCount;
|
||||
}
|
||||
|
||||
public int getDistributionType() {
|
||||
return distributionType;
|
||||
}
|
||||
|
||||
public long getLastSeen() {
|
||||
return lastSeen;
|
||||
}
|
||||
|
||||
private static @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) {
|
||||
return snippet.length() <= MAX_SNIPPET_LENGTH ? snippet
|
||||
: snippet.subSequence(0, MAX_SNIPPET_LENGTH);
|
||||
private void observeDisplayBody(@Nullable LiveData<SpannableString> displayBody) {
|
||||
if (this.displayBody != null) {
|
||||
this.displayBody.removeObserver(this);
|
||||
}
|
||||
|
||||
this.displayBody = displayBody;
|
||||
|
||||
if (this.displayBody != null) {
|
||||
this.displayBody.observeForever(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void setSubjectViewText(@Nullable CharSequence text) {
|
||||
if (text == null) {
|
||||
subjectViewClearDebouncer.publish(() -> subjectView.setText(null));
|
||||
} else {
|
||||
subjectViewClearDebouncer.clear();
|
||||
subjectView.setText(text);
|
||||
subjectView.setVisibility(VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void setThumbnailSnippet(ThreadRecord thread) {
|
||||
@@ -373,7 +385,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
}
|
||||
|
||||
private void setRippleColor(Recipient recipient) {
|
||||
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
|
||||
if (VERSION.SDK_INT >= 21) {
|
||||
((RippleDrawable)(getBackground()).mutate())
|
||||
.setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext())));
|
||||
}
|
||||
@@ -396,16 +408,20 @@ public class ConversationListItem extends RelativeLayout
|
||||
setRippleColor(recipient);
|
||||
}
|
||||
|
||||
|
||||
private static SpannableString getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
|
||||
private static @NonNull LiveData<SpannableString> getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
|
||||
if (thread.getGroupAddedBy() != null) {
|
||||
return emphasisAdded(context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
|
||||
: R.string.ThreadRecord_s_added_you_to_the_group,
|
||||
Recipient.live(thread.getGroupAddedBy()).get().getDisplayName(context)));
|
||||
return emphasisAdded(recipientToStringAsync(thread.getGroupAddedBy(),
|
||||
r -> context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
|
||||
: R.string.ThreadRecord_s_added_you_to_the_group,
|
||||
r.getDisplayName(context))));
|
||||
} else if (!thread.isMessageRequestAccepted()) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_message_request));
|
||||
} else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
|
||||
if (thread.getRecipient().isPushV2Group()) {
|
||||
return emphasisAdded(MessageRecord.getGv2ChangeDescription(context, thread.getBody()));
|
||||
} else {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
|
||||
}
|
||||
} else if (SmsDatabase.Types.isGroupQuit(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
|
||||
} else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) {
|
||||
@@ -420,15 +436,15 @@ public class ConversationListItem extends RelativeLayout
|
||||
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
|
||||
} else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) {
|
||||
String draftText = context.getString(R.string.ThreadRecord_draft);
|
||||
return emphasisAdded(draftText + " " + thread.getBody(), 0, draftText.length());
|
||||
return emphasisAdded(draftText + " " + thread.getBody());
|
||||
} else if (SmsDatabase.Types.isOutgoingCall(thread.getType())) {
|
||||
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called));
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_called));
|
||||
} else if (SmsDatabase.Types.isIncomingCall(thread.getType())) {
|
||||
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called_you));
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_called_you));
|
||||
} else if (SmsDatabase.Types.isMissedCall(thread.getType())) {
|
||||
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_missed_call));
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_missed_call));
|
||||
} else if (SmsDatabase.Types.isJoinedType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, thread.getRecipient().getDisplayName(context)));
|
||||
return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> context.getString(R.string.ThreadRecord_s_is_on_signal, r.getDisplayName(context))));
|
||||
} else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) {
|
||||
int seconds = (int)(thread.getExpiresIn() / 1000);
|
||||
if (seconds <= 0) {
|
||||
@@ -440,7 +456,7 @@ public class ConversationListItem extends RelativeLayout
|
||||
if (thread.getRecipient().isGroup()) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed));
|
||||
} else {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, thread.getRecipient().getDisplayName(context)));
|
||||
return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, r.getDisplayName(context))));
|
||||
}
|
||||
} else if (SmsDatabase.Types.isIdentityVerified(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
|
||||
@@ -448,29 +464,49 @@ public class ConversationListItem extends RelativeLayout
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified));
|
||||
} else if (SmsDatabase.Types.isUnsupportedMessageType(thread.getType())) {
|
||||
return emphasisAdded(context.getString(R.string.ThreadRecord_message_could_not_be_processed));
|
||||
} else if (SmsDatabase.Types.isProfileChange(thread.getType())) {
|
||||
return emphasisAdded("");
|
||||
} else {
|
||||
ThreadDatabase.Extra extra = thread.getExtra();
|
||||
if (extra != null && extra.isViewOnce()) {
|
||||
return new SpannableString(emphasisAdded(getViewOnceDescription(context, thread.getContentType())));
|
||||
return emphasisAdded(getViewOnceDescription(context, thread.getContentType()));
|
||||
} else if (extra != null && extra.isRemoteDelete()) {
|
||||
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_this_message_was_deleted)));
|
||||
return emphasisAdded(context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted));
|
||||
} else {
|
||||
return new SpannableString(Util.emptyIfNull(thread.getBody()));
|
||||
return LiveDataUtil.just(new SpannableString(removeNewlines(thread.getBody())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull SpannableString emphasisAdded(String sequence) {
|
||||
return emphasisAdded(sequence, 0, sequence.length());
|
||||
private static @NonNull String removeNewlines(@Nullable String text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (text.indexOf('\n') >= 0) {
|
||||
return text.replaceAll("\n", " ");
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
private static @NonNull SpannableString emphasisAdded(String sequence, int start, int end) {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC),
|
||||
start,
|
||||
end,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull String string) {
|
||||
return emphasisAdded(UpdateDescription.staticDescription(string));
|
||||
}
|
||||
|
||||
private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull UpdateDescription description) {
|
||||
return emphasisAdded(LiveUpdateMessage.fromMessageDescription(description));
|
||||
}
|
||||
|
||||
private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull LiveData<String> description) {
|
||||
return Transformations.map(description, sequence -> {
|
||||
SpannableString spannable = new SpannableString(sequence);
|
||||
spannable.setSpan(new StyleSpan(Typeface.ITALIC),
|
||||
0,
|
||||
sequence.length(),
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
return spannable;
|
||||
});
|
||||
}
|
||||
|
||||
private static String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) {
|
||||
@@ -483,6 +519,15 @@ public class ConversationListItem extends RelativeLayout
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChanged(SpannableString spannableString) {
|
||||
setSubjectViewText(spannableString);
|
||||
|
||||
if (typingThreads != null) {
|
||||
updateTypingIndicator(typingThreads);
|
||||
}
|
||||
}
|
||||
|
||||
private static class ThumbnailPositioner implements Runnable {
|
||||
|
||||
private final View thumbnailView;
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.text.TextUtils;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.paging.DataSource;
|
||||
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
|
||||
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone;
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones;
|
||||
@@ -29,18 +31,21 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.util.paging.Invalidator;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
class ConversationListViewModel extends ViewModel {
|
||||
|
||||
private final Application application;
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final MutableLiveData<Integer> archivedCount;
|
||||
private final LiveData<ConversationList> conversationList;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer debouncer;
|
||||
private final ContentObserver observer;
|
||||
private final Invalidator invalidator;
|
||||
private static final String TAG = Log.tag(ConversationListViewModel.class);
|
||||
|
||||
private final Application application;
|
||||
private final MutableLiveData<Megaphone> megaphone;
|
||||
private final MutableLiveData<SearchResult> searchResult;
|
||||
private final LiveData<ConversationList> conversationList;
|
||||
private final SearchRepository searchRepository;
|
||||
private final MegaphoneRepository megaphoneRepository;
|
||||
private final Debouncer debouncer;
|
||||
private final ContentObserver observer;
|
||||
private final Invalidator invalidator;
|
||||
|
||||
private String lastQuery;
|
||||
|
||||
@@ -48,7 +53,6 @@ class ConversationListViewModel extends ViewModel {
|
||||
this.application = application;
|
||||
this.megaphone = new MutableLiveData<>();
|
||||
this.searchResult = new MutableLiveData<>();
|
||||
this.archivedCount = new MutableLiveData<>();
|
||||
this.searchRepository = searchRepository;
|
||||
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
|
||||
this.debouncer = new Debouncer(300);
|
||||
@@ -59,10 +63,6 @@ class ConversationListViewModel extends ViewModel {
|
||||
if (!TextUtils.isEmpty(getLastQuery())) {
|
||||
searchRepository.query(getLastQuery(), searchResult::postValue);
|
||||
}
|
||||
|
||||
if (!isArchived) {
|
||||
updateArchivedCount();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,15 +77,32 @@ class ConversationListViewModel extends ViewModel {
|
||||
.setInitialLoadKey(0)
|
||||
.build();
|
||||
|
||||
if (isArchived) {
|
||||
this.archivedCount.setValue(0);
|
||||
} else {
|
||||
updateArchivedCount();
|
||||
}
|
||||
|
||||
application.getContentResolver().registerContentObserver(DatabaseContentProviders.ConversationList.CONTENT_URI, true, observer);
|
||||
|
||||
this.conversationList = LiveDataUtil.combineLatest(conversationList, this.archivedCount, ConversationList::new);
|
||||
this.conversationList = Transformations.switchMap(conversationList, conversation -> {
|
||||
if (conversation.getDataSource().isInvalid()) {
|
||||
Log.w(TAG, "Received an invalid conversation list. Ignoring.");
|
||||
return new MutableLiveData<>();
|
||||
}
|
||||
|
||||
MutableLiveData<ConversationList> updated = new MutableLiveData<>();
|
||||
|
||||
if (isArchived) {
|
||||
updated.postValue(new ConversationList(conversation, 0, 0));
|
||||
} else {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
int archiveCount = DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount();
|
||||
int pinnedCount = DatabaseFactory.getThreadDatabase(application).getPinnedConversationListCount();
|
||||
updated.postValue(new ConversationList(conversation, archiveCount, pinnedCount));
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
public LiveData<Boolean> hasNoConversations() {
|
||||
return Transformations.map(getConversationList(), ConversationList::isEmpty);
|
||||
}
|
||||
|
||||
@NonNull LiveData<SearchResult> getSearchResult() {
|
||||
@@ -100,6 +117,10 @@ class ConversationListViewModel extends ViewModel {
|
||||
return conversationList;
|
||||
}
|
||||
|
||||
public int getPinnedCount() {
|
||||
return Objects.requireNonNull(getConversationList().getValue()).pinnedCount;
|
||||
}
|
||||
|
||||
void onVisible() {
|
||||
megaphoneRepository.getNextMegaphone(megaphone::postValue);
|
||||
}
|
||||
@@ -140,12 +161,6 @@ class ConversationListViewModel extends ViewModel {
|
||||
application.getContentResolver().unregisterContentObserver(observer);
|
||||
}
|
||||
|
||||
private void updateArchivedCount() {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
archivedCount.postValue(DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount());
|
||||
});
|
||||
}
|
||||
|
||||
public static class Factory extends ViewModelProvider.NewInstanceFactory {
|
||||
|
||||
private final boolean isArchived;
|
||||
@@ -164,10 +179,12 @@ class ConversationListViewModel extends ViewModel {
|
||||
final static class ConversationList {
|
||||
private final PagedList<Conversation> conversations;
|
||||
private final int archivedCount;
|
||||
private final int pinnedCount;
|
||||
|
||||
ConversationList(PagedList<Conversation> conversations, int archivedCount) {
|
||||
ConversationList(PagedList<Conversation> conversations, int archivedCount, int pinnedCount) {
|
||||
this.conversations = conversations;
|
||||
this.archivedCount = archivedCount;
|
||||
this.pinnedCount = pinnedCount;
|
||||
}
|
||||
|
||||
PagedList<Conversation> getConversations() {
|
||||
@@ -178,6 +195,10 @@ class ConversationListViewModel extends ViewModel {
|
||||
return archivedCount;
|
||||
}
|
||||
|
||||
public int getPinnedCount() {
|
||||
return pinnedCount;
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return conversations.isEmpty() && archivedCount == 0;
|
||||
}
|
||||
|
||||
@@ -6,15 +6,25 @@ import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
|
||||
public class Conversation {
|
||||
private final ThreadRecord threadRecord;
|
||||
private final Type type;
|
||||
|
||||
public Conversation(@NonNull ThreadRecord threadRecord) {
|
||||
this.threadRecord = threadRecord;
|
||||
if (this.threadRecord.getThreadId() < 0) {
|
||||
type = Type.valueOf(this.threadRecord.getBody());
|
||||
} else {
|
||||
type = Type.THREAD;
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull ThreadRecord getThreadRecord() {
|
||||
return threadRecord;
|
||||
}
|
||||
|
||||
public @NonNull Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
@@ -27,4 +37,11 @@ public class Conversation {
|
||||
public int hashCode() {
|
||||
return threadRecord.hashCode();
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
THREAD,
|
||||
PINNED_HEADER,
|
||||
UNPINNED_HEADER,
|
||||
ARCHIVED_FOOTER
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.conversationlist.model;
|
||||
|
||||
import android.database.Cursor;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
|
||||
public class ConversationReader extends ThreadDatabase.StaticReader {
|
||||
|
||||
public static final String[] HEADER_COLUMN = {"header"};
|
||||
public static final String[] ARCHIVED_COLUMNS = {"header", "count"};
|
||||
public static final String[] PINNED_HEADER = {Conversation.Type.PINNED_HEADER.toString()};
|
||||
public static final String[] UNPINNED_HEADER = {Conversation.Type.UNPINNED_HEADER.toString()};
|
||||
|
||||
private final Cursor cursor;
|
||||
|
||||
public ConversationReader(@NonNull Cursor cursor) {
|
||||
super(cursor, ApplicationDependencies.getApplication());
|
||||
this.cursor = cursor;
|
||||
}
|
||||
|
||||
public static String[] createArchivedFooterRow(int archivedCount) {
|
||||
return new String[]{Conversation.Type.ARCHIVED_FOOTER.toString(), String.valueOf(archivedCount)};
|
||||
}
|
||||
|
||||
@Override
|
||||
public ThreadRecord getCurrent() {
|
||||
if (cursor.getColumnIndex(HEADER_COLUMN[0]) == -1) {
|
||||
return super.getCurrent();
|
||||
} else {
|
||||
return buildThreadRecordForHeader();
|
||||
}
|
||||
}
|
||||
|
||||
private ThreadRecord buildThreadRecordForHeader() {
|
||||
Conversation.Type type = Conversation.Type.valueOf(CursorUtil.requireString(cursor, HEADER_COLUMN[0]));
|
||||
int count = 0;
|
||||
if (type == Conversation.Type.ARCHIVED_FOOTER) {
|
||||
count = CursorUtil.requireInt(cursor, ARCHIVED_COLUMNS[1]);
|
||||
}
|
||||
return new ThreadRecord.Builder(-(100 + type.ordinal()))
|
||||
.setBody(type.toString())
|
||||
.setDate(100)
|
||||
.setRecipient(Recipient.UNKNOWN)
|
||||
.setCount(count)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -11,20 +11,29 @@ public class MessageResult {
|
||||
|
||||
public final Recipient conversationRecipient;
|
||||
public final Recipient messageRecipient;
|
||||
public final String body;
|
||||
public final String bodySnippet;
|
||||
public final long threadId;
|
||||
public final long messageId;
|
||||
public final long receivedTimestampMs;
|
||||
public final boolean isMms;
|
||||
|
||||
public MessageResult(@NonNull Recipient conversationRecipient,
|
||||
@NonNull Recipient messageRecipient,
|
||||
@NonNull String body,
|
||||
@NonNull String bodySnippet,
|
||||
long threadId,
|
||||
long receivedTimestampMs)
|
||||
long messageId,
|
||||
long receivedTimestampMs,
|
||||
boolean isMms)
|
||||
{
|
||||
this.conversationRecipient = conversationRecipient;
|
||||
this.messageRecipient = messageRecipient;
|
||||
this.body = body;
|
||||
this.bodySnippet = bodySnippet;
|
||||
this.threadId = threadId;
|
||||
this.messageId = messageId;
|
||||
this.receivedTimestampMs = receivedTimestampMs;
|
||||
this.isMms = isMms;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.BitmapDecodingException;
|
||||
import org.thoughtcrime.securesms.util.BitmapUtil;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.FileUtils;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.thoughtcrime.securesms.util.MediaMetadataRetrieverUtil;
|
||||
@@ -112,6 +113,7 @@ public class AttachmentDatabase extends Database {
|
||||
public static final String UNIQUE_ID = "unique_id";
|
||||
static final String DIGEST = "digest";
|
||||
static final String VOICE_NOTE = "voice_note";
|
||||
static final String BORDERLESS = "borderless";
|
||||
static final String QUOTE = "quote";
|
||||
public static final String STICKER_PACK_ID = "sticker_pack_id";
|
||||
public static final String STICKER_PACK_KEY = "sticker_pack_key";
|
||||
@@ -146,7 +148,7 @@ public class AttachmentDatabase extends Database {
|
||||
CDN_NUMBER, CONTENT_LOCATION, DATA, THUMBNAIL,
|
||||
TRANSFER_STATE, SIZE, FILE_NAME, THUMBNAIL,
|
||||
THUMBNAIL_ASPECT_RATIO, UNIQUE_ID, DIGEST,
|
||||
FAST_PREFLIGHT_ID, VOICE_NOTE, QUOTE, DATA_RANDOM,
|
||||
FAST_PREFLIGHT_ID, VOICE_NOTE, BORDERLESS, QUOTE, DATA_RANDOM,
|
||||
THUMBNAIL_RANDOM, WIDTH, HEIGHT, CAPTION, STICKER_PACK_ID,
|
||||
STICKER_PACK_KEY, STICKER_ID, DATA_HASH, VISUAL_HASH,
|
||||
TRANSFORM_PROPERTIES, TRANSFER_FILE, DISPLAY_ORDER,
|
||||
@@ -175,6 +177,7 @@ public class AttachmentDatabase extends Database {
|
||||
DIGEST + " BLOB, " +
|
||||
FAST_PREFLIGHT_ID + " TEXT, " +
|
||||
VOICE_NOTE + " INTEGER DEFAULT 0, " +
|
||||
BORDERLESS + " INTEGER DEFAULT 0, " +
|
||||
DATA_RANDOM + " BLOB, " +
|
||||
THUMBNAIL_RANDOM + " BLOB, " +
|
||||
QUOTE + " INTEGER DEFAULT 0, " +
|
||||
@@ -306,6 +309,23 @@ public class AttachmentDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasAttachment(@NonNull AttachmentId id) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME,
|
||||
new String[]{ROW_ID, UNIQUE_ID},
|
||||
PART_ID_WHERE,
|
||||
id.toStrings(),
|
||||
null,
|
||||
null,
|
||||
null)) {
|
||||
if (cursor != null && cursor.getCount() > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean hasAttachmentFilesForMessage(long mmsId) {
|
||||
String selection = MMS_ID + " = ? AND (" + DATA + " NOT NULL OR " + TRANSFER_STATE + " != ?)";
|
||||
String[] args = new String[] { String.valueOf(mmsId), String.valueOf(TRANSFER_PROGRESS_DONE) };
|
||||
@@ -1168,6 +1188,7 @@ public class AttachmentDatabase extends Database {
|
||||
null,
|
||||
object.getString(FAST_PREFLIGHT_ID),
|
||||
object.getInt(VOICE_NOTE) == 1,
|
||||
object.getInt(BORDERLESS) == 1,
|
||||
object.getInt(WIDTH),
|
||||
object.getInt(HEIGHT),
|
||||
object.getInt(QUOTE) == 1,
|
||||
@@ -1204,6 +1225,7 @@ public class AttachmentDatabase extends Database {
|
||||
cursor.getBlob(cursor.getColumnIndexOrThrow(DIGEST)),
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(FAST_PREFLIGHT_ID)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(VOICE_NOTE)) == 1,
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(BORDERLESS)) == 1,
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(WIDTH)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(HEIGHT)),
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE)) == 1,
|
||||
@@ -1269,6 +1291,7 @@ public class AttachmentDatabase extends Database {
|
||||
contentValues.put(SIZE, template.getSize());
|
||||
contentValues.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId());
|
||||
contentValues.put(VOICE_NOTE, attachment.isVoiceNote() ? 1 : 0);
|
||||
contentValues.put(BORDERLESS, attachment.isBorderless() ? 1 : 0);
|
||||
contentValues.put(WIDTH, template.getWidth());
|
||||
contentValues.put(HEIGHT, template.getHeight());
|
||||
contentValues.put(QUOTE, quote);
|
||||
|
||||
@@ -67,6 +67,10 @@ public abstract class Database {
|
||||
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForThread(threadId));
|
||||
}
|
||||
|
||||
protected void setNotifyConversationListeners(Cursor cursor) {
|
||||
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getUriForAllThreads());
|
||||
}
|
||||
|
||||
protected void setNotifyVerboseConversationListeners(Cursor cursor, long threadId) {
|
||||
cursor.setNotificationUri(context.getContentResolver(), DatabaseContentProviders.Conversation.getVerboseUriForThread(threadId));
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ public class DatabaseContentProviders {
|
||||
public static Uri getVerboseUriForThread(long threadId) {
|
||||
return Uri.parse(CONTENT_URI_STRING + "verbose/" + threadId);
|
||||
}
|
||||
|
||||
public static Uri getUriForAllThreads() {
|
||||
return Uri.parse(CONTENT_URI_STRING);
|
||||
}
|
||||
}
|
||||
|
||||
public static class Attachment extends NoopContentProvider {
|
||||
|
||||
@@ -39,29 +39,31 @@ public class DatabaseFactory {
|
||||
|
||||
private static DatabaseFactory instance;
|
||||
|
||||
private final SQLCipherOpenHelper databaseHelper;
|
||||
private final SmsDatabase sms;
|
||||
private final MmsDatabase mms;
|
||||
private final AttachmentDatabase attachments;
|
||||
private final MediaDatabase media;
|
||||
private final ThreadDatabase thread;
|
||||
private final MmsSmsDatabase mmsSmsDatabase;
|
||||
private final IdentityDatabase identityDatabase;
|
||||
private final DraftDatabase draftDatabase;
|
||||
private final PushDatabase pushDatabase;
|
||||
private final GroupDatabase groupDatabase;
|
||||
private final RecipientDatabase recipientDatabase;
|
||||
private final ContactsDatabase contactsDatabase;
|
||||
private final GroupReceiptDatabase groupReceiptDatabase;
|
||||
private final OneTimePreKeyDatabase preKeyDatabase;
|
||||
private final SignedPreKeyDatabase signedPreKeyDatabase;
|
||||
private final SessionDatabase sessionDatabase;
|
||||
private final SearchDatabase searchDatabase;
|
||||
private final JobDatabase jobDatabase;
|
||||
private final StickerDatabase stickerDatabase;
|
||||
private final StorageKeyDatabase storageKeyDatabase;
|
||||
private final KeyValueDatabase keyValueDatabase;
|
||||
private final MegaphoneDatabase megaphoneDatabase;
|
||||
private final SQLCipherOpenHelper databaseHelper;
|
||||
private final SmsDatabase sms;
|
||||
private final MmsDatabase mms;
|
||||
private final AttachmentDatabase attachments;
|
||||
private final MediaDatabase media;
|
||||
private final ThreadDatabase thread;
|
||||
private final MmsSmsDatabase mmsSmsDatabase;
|
||||
private final IdentityDatabase identityDatabase;
|
||||
private final DraftDatabase draftDatabase;
|
||||
private final PushDatabase pushDatabase;
|
||||
private final GroupDatabase groupDatabase;
|
||||
private final RecipientDatabase recipientDatabase;
|
||||
private final ContactsDatabase contactsDatabase;
|
||||
private final GroupReceiptDatabase groupReceiptDatabase;
|
||||
private final OneTimePreKeyDatabase preKeyDatabase;
|
||||
private final SignedPreKeyDatabase signedPreKeyDatabase;
|
||||
private final SessionDatabase sessionDatabase;
|
||||
private final SearchDatabase searchDatabase;
|
||||
private final JobDatabase jobDatabase;
|
||||
private final StickerDatabase stickerDatabase;
|
||||
private final StorageKeyDatabase storageKeyDatabase;
|
||||
private final KeyValueDatabase keyValueDatabase;
|
||||
private final MegaphoneDatabase megaphoneDatabase;
|
||||
private final RemappedRecordsDatabase remappedRecordsDatabase;
|
||||
private final MentionDatabase mentionDatabase;
|
||||
|
||||
public static DatabaseFactory getInstance(Context context) {
|
||||
synchronized (lock) {
|
||||
@@ -160,6 +162,14 @@ public class DatabaseFactory {
|
||||
return getInstance(context).megaphoneDatabase;
|
||||
}
|
||||
|
||||
static RemappedRecordsDatabase getRemappedRecordsDatabase(Context context) {
|
||||
return getInstance(context).remappedRecordsDatabase;
|
||||
}
|
||||
|
||||
public static MentionDatabase getMentionDatabase(Context context) {
|
||||
return getInstance(context).mentionDatabase;
|
||||
}
|
||||
|
||||
public static SQLiteDatabase getBackupDatabase(Context context) {
|
||||
return getInstance(context).databaseHelper.getReadableDatabase();
|
||||
}
|
||||
@@ -175,8 +185,8 @@ public class DatabaseFactory {
|
||||
}
|
||||
}
|
||||
|
||||
static SQLCipherOpenHelper getRawDatabase(Context context) {
|
||||
return getInstance(context).databaseHelper;
|
||||
public static boolean inTransaction(Context context) {
|
||||
return getInstance(context).databaseHelper.getWritableDatabase().inTransaction();
|
||||
}
|
||||
|
||||
private DatabaseFactory(@NonNull Context context) {
|
||||
@@ -185,29 +195,31 @@ public class DatabaseFactory {
|
||||
DatabaseSecret databaseSecret = new DatabaseSecretProvider(context).getOrCreateDatabaseSecret();
|
||||
AttachmentSecret attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret();
|
||||
|
||||
this.databaseHelper = new SQLCipherOpenHelper(context, databaseSecret);
|
||||
this.sms = new SmsDatabase(context, databaseHelper);
|
||||
this.mms = new MmsDatabase(context, databaseHelper);
|
||||
this.attachments = new AttachmentDatabase(context, databaseHelper, attachmentSecret);
|
||||
this.media = new MediaDatabase(context, databaseHelper);
|
||||
this.thread = new ThreadDatabase(context, databaseHelper);
|
||||
this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper);
|
||||
this.identityDatabase = new IdentityDatabase(context, databaseHelper);
|
||||
this.draftDatabase = new DraftDatabase(context, databaseHelper);
|
||||
this.pushDatabase = new PushDatabase(context, databaseHelper);
|
||||
this.groupDatabase = new GroupDatabase(context, databaseHelper);
|
||||
this.recipientDatabase = new RecipientDatabase(context, databaseHelper);
|
||||
this.groupReceiptDatabase = new GroupReceiptDatabase(context, databaseHelper);
|
||||
this.contactsDatabase = new ContactsDatabase(context);
|
||||
this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper);
|
||||
this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper);
|
||||
this.sessionDatabase = new SessionDatabase(context, databaseHelper);
|
||||
this.searchDatabase = new SearchDatabase(context, databaseHelper);
|
||||
this.jobDatabase = new JobDatabase(context, databaseHelper);
|
||||
this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret);
|
||||
this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper);
|
||||
this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper);
|
||||
this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper);
|
||||
this.databaseHelper = new SQLCipherOpenHelper(context, databaseSecret);
|
||||
this.sms = new SmsDatabase(context, databaseHelper);
|
||||
this.mms = new MmsDatabase(context, databaseHelper);
|
||||
this.attachments = new AttachmentDatabase(context, databaseHelper, attachmentSecret);
|
||||
this.media = new MediaDatabase(context, databaseHelper);
|
||||
this.thread = new ThreadDatabase(context, databaseHelper);
|
||||
this.mmsSmsDatabase = new MmsSmsDatabase(context, databaseHelper);
|
||||
this.identityDatabase = new IdentityDatabase(context, databaseHelper);
|
||||
this.draftDatabase = new DraftDatabase(context, databaseHelper);
|
||||
this.pushDatabase = new PushDatabase(context, databaseHelper);
|
||||
this.groupDatabase = new GroupDatabase(context, databaseHelper);
|
||||
this.recipientDatabase = new RecipientDatabase(context, databaseHelper);
|
||||
this.groupReceiptDatabase = new GroupReceiptDatabase(context, databaseHelper);
|
||||
this.contactsDatabase = new ContactsDatabase(context);
|
||||
this.preKeyDatabase = new OneTimePreKeyDatabase(context, databaseHelper);
|
||||
this.signedPreKeyDatabase = new SignedPreKeyDatabase(context, databaseHelper);
|
||||
this.sessionDatabase = new SessionDatabase(context, databaseHelper);
|
||||
this.searchDatabase = new SearchDatabase(context, databaseHelper);
|
||||
this.jobDatabase = new JobDatabase(context, databaseHelper);
|
||||
this.stickerDatabase = new StickerDatabase(context, databaseHelper, attachmentSecret);
|
||||
this.storageKeyDatabase = new StorageKeyDatabase(context, databaseHelper);
|
||||
this.keyValueDatabase = new KeyValueDatabase(context, databaseHelper);
|
||||
this.megaphoneDatabase = new MegaphoneDatabase(context, databaseHelper);
|
||||
this.remappedRecordsDatabase = new RemappedRecordsDatabase(context, databaseHelper);
|
||||
this.mentionDatabase = new MentionDatabase(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void onApplicationLevelUpgrade(@NonNull Context context, @NonNull MasterSecret masterSecret,
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
@@ -17,7 +19,7 @@ import java.util.Set;
|
||||
|
||||
public class DraftDatabase extends Database {
|
||||
|
||||
private static final String TABLE_NAME = "drafts";
|
||||
static final String TABLE_NAME = "drafts";
|
||||
public static final String ID = "_id";
|
||||
public static final String THREAD_ID = "thread_id";
|
||||
public static final String DRAFT_TYPE = "type";
|
||||
@@ -73,14 +75,11 @@ public class DraftDatabase extends Database {
|
||||
db.delete(TABLE_NAME, null, null);
|
||||
}
|
||||
|
||||
public List<Draft> getDrafts(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
List<Draft> results = new LinkedList<>();
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null);
|
||||
public Drafts getDrafts(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
Drafts results = new Drafts();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, THREAD_ID + " = ?", new String[] {threadId+""}, null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
String type = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_TYPE));
|
||||
String value = cursor.getString(cursor.getColumnIndexOrThrow(DRAFT_VALUE));
|
||||
@@ -89,9 +88,6 @@ public class DraftDatabase extends Database {
|
||||
}
|
||||
|
||||
return results;
|
||||
} finally {
|
||||
if (cursor != null)
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +98,7 @@ public class DraftDatabase extends Database {
|
||||
public static final String AUDIO = "audio";
|
||||
public static final String LOCATION = "location";
|
||||
public static final String QUOTE = "quote";
|
||||
public static final String MENTION = "mention";
|
||||
|
||||
private final String type;
|
||||
private final String value;
|
||||
@@ -133,7 +130,7 @@ public class DraftDatabase extends Database {
|
||||
}
|
||||
|
||||
public static class Drafts extends LinkedList<Draft> {
|
||||
private Draft getDraftOfType(String type) {
|
||||
public @Nullable Draft getDraftOfType(String type) {
|
||||
for (Draft draft : this) {
|
||||
if (type.equals(draft.getType())) {
|
||||
return draft;
|
||||
@@ -142,7 +139,7 @@ public class DraftDatabase extends Database {
|
||||
return null;
|
||||
}
|
||||
|
||||
public String getSnippet(Context context) {
|
||||
public @NonNull String getSnippet(Context context) {
|
||||
Draft textDraft = getDraftOfType(Draft.TEXT);
|
||||
if (textDraft != null) {
|
||||
return textDraft.getSnippet(context);
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||
@@ -52,7 +53,7 @@ public final class GroupDatabase extends Database {
|
||||
static final String GROUP_ID = "group_id";
|
||||
static final String RECIPIENT_ID = "recipient_id";
|
||||
private static final String TITLE = "title";
|
||||
private static final String MEMBERS = "members";
|
||||
static final String MEMBERS = "members";
|
||||
private static final String AVATAR_ID = "avatar_id";
|
||||
private static final String AVATAR_KEY = "avatar_key";
|
||||
private static final String AVATAR_CONTENT_TYPE = "avatar_content_type";
|
||||
@@ -96,7 +97,7 @@ public final class GroupDatabase extends Database {
|
||||
|
||||
private static final String[] GROUP_PROJECTION = {
|
||||
GROUP_ID, RECIPIENT_ID, TITLE, MEMBERS, AVATAR_ID, AVATAR_KEY, AVATAR_CONTENT_TYPE, AVATAR_RELAY, AVATAR_DIGEST,
|
||||
TIMESTAMP, ACTIVE, MMS
|
||||
TIMESTAMP, ACTIVE, MMS, V2_MASTER_KEY, V2_REVISION, V2_DECRYPTED_GROUP
|
||||
};
|
||||
|
||||
static final List<String> TYPED_GROUP_PROJECTION = Stream.of(GROUP_PROJECTION).map(columnName -> TABLE_NAME + "." + columnName).toList();
|
||||
@@ -224,12 +225,31 @@ public final class GroupDatabase extends Database {
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull List<GroupRecord> getPushGroupsContainingMember(@NonNull RecipientId recipientId) {
|
||||
return getGroupsContainingMember(recipientId, true);
|
||||
}
|
||||
|
||||
public @NonNull List<GroupRecord> getGroupsContainingMember(@NonNull RecipientId recipientId, boolean pushOnly) {
|
||||
return getGroupsContainingMember(recipientId, pushOnly, false);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull List<GroupRecord> getGroupsContainingMember(@NonNull RecipientId recipientId, boolean pushOnly, boolean includeInactive) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String table = TABLE_NAME + " INNER JOIN " + ThreadDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + RECIPIENT_ID + " = " + ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.RECIPIENT_ID;
|
||||
String query = MEMBERS + " LIKE ? AND " + MMS + " = ?";
|
||||
String[] args = new String[]{"%" + recipientId.serialize() + "%", "0"};
|
||||
String query = MEMBERS + " LIKE ?";
|
||||
String[] args = SqlUtil.buildArgs("%" + recipientId.serialize() + "%");
|
||||
String orderBy = ThreadDatabase.TABLE_NAME + "." + ThreadDatabase.DATE + " DESC";
|
||||
|
||||
if (pushOnly) {
|
||||
query += " AND " + MMS + " = ?";
|
||||
args = SqlUtil.appendArg(args, "0");
|
||||
}
|
||||
|
||||
if (!includeInactive) {
|
||||
query += " AND " + ACTIVE + " = ?";
|
||||
args = SqlUtil.appendArg(args, "1");
|
||||
}
|
||||
|
||||
List<GroupRecord> groups = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = database.query(table, null, query, args, null, null, orderBy)) {
|
||||
@@ -251,6 +271,20 @@ public final class GroupDatabase extends Database {
|
||||
return new Reader(cursor);
|
||||
}
|
||||
|
||||
public int getActiveGroupCount() {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String[] cols = { "COUNT(*)" };
|
||||
String query = ACTIVE + " = 1";
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, cols, query, null, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getInt(0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public @NonNull List<Recipient> getGroupMembers(@NonNull GroupId groupId, @NonNull MemberSet memberSet) {
|
||||
if (groupId.isV2()) {
|
||||
@@ -261,8 +295,9 @@ public final class GroupDatabase extends Database {
|
||||
List<Recipient> recipients = new ArrayList<>(currentMembers.size());
|
||||
|
||||
for (RecipientId member : currentMembers) {
|
||||
if (memberSet.includeSelf || !Recipient.resolved(member).isLocalNumber()) {
|
||||
recipients.add(Recipient.resolved(member));
|
||||
Recipient resolved = Recipient.resolved(member);
|
||||
if (memberSet.includeSelf || !resolved.isLocalNumber()) {
|
||||
recipients.add(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,7 +606,7 @@ public final class GroupDatabase extends Database {
|
||||
}
|
||||
|
||||
public @Nullable GroupRecord getCurrent() {
|
||||
if (cursor == null || cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)) == null) {
|
||||
if (cursor == null || cursor.getString(cursor.getColumnIndexOrThrow(GROUP_ID)) == null || cursor.getLong(cursor.getColumnIndexOrThrow(RECIPIENT_ID)) == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -713,6 +748,15 @@ public final class GroupDatabase extends Database {
|
||||
return isV2Group() && requireV2GroupProperties().isAdmin(recipient);
|
||||
}
|
||||
|
||||
public MemberLevel memberLevel(@NonNull Recipient recipient) {
|
||||
if (isV2Group()) {
|
||||
return requireV2GroupProperties().memberLevel(recipient);
|
||||
} else {
|
||||
return members.contains(recipient.getId()) ? MemberLevel.FULL_MEMBER
|
||||
: MemberLevel.NOT_A_MEMBER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who is allowed to add to the membership of this group.
|
||||
*/
|
||||
@@ -791,10 +835,20 @@ public final class GroupDatabase extends Database {
|
||||
.or(false);
|
||||
}
|
||||
|
||||
public MemberLevel memberLevel(@NonNull Recipient recipient) {
|
||||
DecryptedGroup decryptedGroup = getDecryptedGroup();
|
||||
|
||||
return DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), recipient.getUuid().get())
|
||||
.transform(member -> member.getRole() == Member.Role.ADMINISTRATOR
|
||||
? MemberLevel.ADMINISTRATOR
|
||||
: MemberLevel.FULL_MEMBER)
|
||||
.or(() -> DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), recipient.getUuid().get())
|
||||
.isPresent() ? MemberLevel.PENDING_MEMBER
|
||||
: MemberLevel.NOT_A_MEMBER);
|
||||
}
|
||||
|
||||
public List<Recipient> getMemberRecipients(@NonNull MemberSet memberSet) {
|
||||
return Stream.of(getMemberRecipientIds(memberSet))
|
||||
.map(Recipient::resolved)
|
||||
.toList();
|
||||
return Recipient.resolvedList(getMemberRecipientIds(memberSet));
|
||||
}
|
||||
|
||||
public List<RecipientId> getMemberRecipientIds(@NonNull MemberSet memberSet) {
|
||||
@@ -844,4 +898,21 @@ public final class GroupDatabase extends Database {
|
||||
this.includePending = includePending;
|
||||
}
|
||||
}
|
||||
|
||||
public enum MemberLevel {
|
||||
NOT_A_MEMBER(false),
|
||||
PENDING_MEMBER(false),
|
||||
FULL_MEMBER(true),
|
||||
ADMINISTRATOR(true);
|
||||
|
||||
private final boolean inGroup;
|
||||
|
||||
MemberLevel(boolean inGroup){
|
||||
this.inGroup = inGroup;
|
||||
}
|
||||
|
||||
public boolean isInGroup() {
|
||||
return inGroup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class GroupReceiptDatabase extends Database {
|
||||
|
||||
private static final String ID = "_id";
|
||||
public static final String MMS_ID = "mms_id";
|
||||
private static final String RECIPIENT_ID = "address";
|
||||
static final String RECIPIENT_ID = "address";
|
||||
private static final String STATUS = "status";
|
||||
private static final String TIMESTAMP = "timestamp";
|
||||
private static final String UNIDENTIFIED = "unidentified";
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class IdentityDatabase extends Database {
|
||||
@@ -115,9 +116,9 @@ public class IdentityDatabase extends Database {
|
||||
}
|
||||
|
||||
public @NonNull IdentityRecordList getIdentities(@NonNull List<Recipient> recipients) {
|
||||
IdentityRecordList identityRecordList = new IdentityRecordList();
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String[] selectionArgs = new String[1];
|
||||
List<IdentityRecord> records = new LinkedList<>();
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String[] selectionArgs = new String[1];
|
||||
|
||||
database.beginTransaction();
|
||||
try {
|
||||
@@ -126,7 +127,7 @@ public class IdentityDatabase extends Database {
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, null, RECIPIENT_ID + " = ?", selectionArgs, null, null, null)) {
|
||||
if (cursor.moveToFirst()) {
|
||||
identityRecordList.add(getIdentityRecord(cursor));
|
||||
records.add(getIdentityRecord(cursor));
|
||||
}
|
||||
} catch (InvalidKeyException | IOException e) {
|
||||
throw new AssertionError(e);
|
||||
@@ -136,7 +137,7 @@ public class IdentityDatabase extends Database {
|
||||
database.endTransaction();
|
||||
}
|
||||
|
||||
return identityRecordList;
|
||||
return new IdentityRecordList(records);
|
||||
}
|
||||
|
||||
public void saveIdentity(@NonNull RecipientId recipientId, IdentityKey identityKey, VerifiedStatus verifiedStatus,
|
||||
@@ -179,7 +180,7 @@ public class IdentityDatabase extends Database {
|
||||
boolean statusMatches = keyMatches && hasMatchingStatus(id, identityKey, verifiedStatus);
|
||||
|
||||
if (!keyMatches || !statusMatches) {
|
||||
saveIdentityInternal(id, identityKey, verifiedStatus, false, System.currentTimeMillis(), true);
|
||||
saveIdentityInternal(id, identityKey, verifiedStatus, !hadEntry, System.currentTimeMillis(), true);
|
||||
Optional<IdentityRecord> record = getIdentity(id);
|
||||
if (record.isPresent()) EventBus.getDefault().post(record.get());
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ public class MediaDatabase extends Database {
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DIGEST + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BORDERLESS + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + ", "
|
||||
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", "
|
||||
@@ -88,11 +89,19 @@ public class MediaDatabase extends Database {
|
||||
}
|
||||
|
||||
public @NonNull Cursor getGalleryMediaForThread(long threadId, @NonNull Sorting sorting) {
|
||||
return getGalleryMediaForThread(threadId, sorting, false);
|
||||
}
|
||||
|
||||
public @NonNull Cursor getGalleryMediaForThread(long threadId, @NonNull Sorting sorting, boolean listenToAllThreads) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String query = sorting.applyToQuery(applyEqualityOperator(threadId, GALLERY_MEDIA_QUERY));
|
||||
String[] args = {threadId + ""};
|
||||
Cursor cursor = database.rawQuery(query, args);
|
||||
setNotifyConversationListeners(cursor, threadId);
|
||||
if (listenToAllThreads) {
|
||||
setNotifyConversationListeners(cursor);
|
||||
} else {
|
||||
setNotifyConversationListeners(cursor, threadId);
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import net.sqlcipher.database.SQLiteDatabase;
|
||||
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class MentionDatabase extends Database {
|
||||
|
||||
static final String TABLE_NAME = "mention";
|
||||
|
||||
private static final String ID = "_id";
|
||||
static final String THREAD_ID = "thread_id";
|
||||
private static final String MESSAGE_ID = "message_id";
|
||||
static final String RECIPIENT_ID = "recipient_id";
|
||||
private static final String RANGE_START = "range_start";
|
||||
private static final String RANGE_LENGTH = "range_length";
|
||||
|
||||
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + "(" + ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
|
||||
THREAD_ID + " INTEGER, " +
|
||||
MESSAGE_ID + " INTEGER, " +
|
||||
RECIPIENT_ID + " INTEGER, " +
|
||||
RANGE_START + " INTEGER, " +
|
||||
RANGE_LENGTH + " INTEGER)";
|
||||
|
||||
public static final String[] CREATE_INDEXES = new String[] {
|
||||
"CREATE INDEX IF NOT EXISTS mention_message_id_index ON " + TABLE_NAME + " (" + MESSAGE_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mention_recipient_id_thread_id_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ", " + THREAD_ID + ");"
|
||||
};
|
||||
|
||||
public MentionDatabase(@NonNull Context context, @NonNull SQLCipherOpenHelper databaseHelper) {
|
||||
super(context, databaseHelper);
|
||||
}
|
||||
|
||||
public void insert(long threadId, long messageId, @NonNull Collection<Mention> mentions) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
for (Mention mention : mentions) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(THREAD_ID, threadId);
|
||||
values.put(MESSAGE_ID, messageId);
|
||||
values.put(RECIPIENT_ID, mention.getRecipientId().toLong());
|
||||
values.put(RANGE_START, mention.getStart());
|
||||
values.put(RANGE_LENGTH, mention.getLength());
|
||||
db.insert(TABLE_NAME, null, values);
|
||||
}
|
||||
|
||||
db.setTransactionSuccessful();
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentionsForMessage(long messageId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
List<Mention> mentions = new LinkedList<>();
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, MESSAGE_ID + " = ?", SqlUtil.buildArgs(messageId), null, null, null)) {
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
mentions.add(new Mention(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)),
|
||||
CursorUtil.requireInt(cursor, RANGE_START),
|
||||
CursorUtil.requireInt(cursor, RANGE_LENGTH)));
|
||||
}
|
||||
}
|
||||
|
||||
return mentions;
|
||||
}
|
||||
|
||||
public @NonNull Map<Long, List<Mention>> getMentionsForMessages(@NonNull Collection<Long> messageIds) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String ids = TextUtils.join(",", messageIds);
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, null, MESSAGE_ID + " IN (" + ids + ")", null, null, null, null)) {
|
||||
return readMentions(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
public @NonNull Map<Long, List<Mention>> getMentionsContainingRecipients(@NonNull Collection<RecipientId> recipientIds, long limit) {
|
||||
return getMentionsContainingRecipients(recipientIds, -1, limit);
|
||||
}
|
||||
|
||||
public @NonNull Map<Long, List<Mention>> getMentionsContainingRecipients(@NonNull Collection<RecipientId> recipientIds, long threadId, long limit) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String ids = TextUtils.join(",", Stream.of(recipientIds).map(RecipientId::serialize).toList());
|
||||
|
||||
String where = " WHERE " + RECIPIENT_ID + " IN (" + ids + ")";
|
||||
if (threadId != -1) {
|
||||
where += " AND " + THREAD_ID + " = " + threadId;
|
||||
}
|
||||
|
||||
String subSelect = "SELECT DISTINCT " + MESSAGE_ID +
|
||||
" FROM " + TABLE_NAME +
|
||||
where +
|
||||
" ORDER BY " + ID + " DESC" +
|
||||
" LIMIT " + limit;
|
||||
|
||||
String query = "SELECT *" +
|
||||
" FROM " + TABLE_NAME +
|
||||
" WHERE " + MESSAGE_ID +
|
||||
" IN (" + subSelect + ")";
|
||||
|
||||
try (Cursor cursor = db.rawQuery(query, null)) {
|
||||
return readMentions(cursor);
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull Map<Long, List<Mention>> readMentions(@Nullable Cursor cursor) {
|
||||
Map<Long, List<Mention>> mentions = new HashMap<>();
|
||||
while (cursor != null && cursor.moveToNext()) {
|
||||
long messageId = CursorUtil.requireLong(cursor, MESSAGE_ID);
|
||||
List<Mention> messageMentions = mentions.get(messageId);
|
||||
|
||||
if (messageMentions == null) {
|
||||
messageMentions = new LinkedList<>();
|
||||
mentions.put(messageId, messageMentions);
|
||||
}
|
||||
|
||||
messageMentions.add(new Mention(RecipientId.from(CursorUtil.requireLong(cursor, RECIPIENT_ID)),
|
||||
CursorUtil.requireInt(cursor, RANGE_START),
|
||||
CursorUtil.requireInt(cursor, RANGE_LENGTH)));
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package org.thoughtcrime.securesms.database;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.SpannableStringBuilder;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.annimon.stream.function.Function;
|
||||
import com.google.protobuf.InvalidProtocolBufferException;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase.MentionSetting;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public final class MentionUtil {
|
||||
|
||||
public static final char MENTION_STARTER = '@';
|
||||
static final String MENTION_PLACEHOLDER = "\uFFFC";
|
||||
|
||||
private MentionUtil() { }
|
||||
|
||||
@WorkerThread
|
||||
public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord) {
|
||||
return updateBodyWithDisplayNames(context, messageRecord, messageRecord.getDisplayBody(context));
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static @NonNull CharSequence updateBodyWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull CharSequence body) {
|
||||
if (messageRecord.isMms()) {
|
||||
List<Mention> mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(messageRecord.getId());
|
||||
CharSequence updated = updateBodyAndMentionsWithDisplayNames(context, body, mentions).getBody();
|
||||
if (updated != null) {
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithDisplayNames(@NonNull Context context, @NonNull MessageRecord messageRecord, @NonNull List<Mention> mentions) {
|
||||
return updateBodyAndMentionsWithDisplayNames(context, messageRecord.getDisplayBody(context), mentions);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithDisplayNames(@NonNull Context context, @NonNull CharSequence body, @NonNull List<Mention> mentions) {
|
||||
return update(body, mentions, m -> MENTION_STARTER + Recipient.resolved(m.getRecipientId()).getMentionDisplayName(context));
|
||||
}
|
||||
|
||||
public static @NonNull UpdatedBodyAndMentions updateBodyAndMentionsWithPlaceholders(@Nullable CharSequence body, @NonNull List<Mention> mentions) {
|
||||
return update(body, mentions, m -> MENTION_PLACEHOLDER);
|
||||
}
|
||||
|
||||
private static @NonNull UpdatedBodyAndMentions update(@Nullable CharSequence body, @NonNull List<Mention> mentions, @NonNull Function<Mention, CharSequence> replacementTextGenerator) {
|
||||
if (body == null || mentions.isEmpty()) {
|
||||
return new UpdatedBodyAndMentions(body, mentions);
|
||||
}
|
||||
|
||||
SpannableStringBuilder updatedBody = new SpannableStringBuilder();
|
||||
List<Mention> updatedMentions = new ArrayList<>();
|
||||
|
||||
Collections.sort(mentions);
|
||||
|
||||
int bodyIndex = 0;
|
||||
|
||||
for (Mention mention : mentions) {
|
||||
updatedBody.append(body.subSequence(bodyIndex, mention.getStart()));
|
||||
CharSequence replaceWith = replacementTextGenerator.apply(mention);
|
||||
Mention updatedMention = new Mention(mention.getRecipientId(), updatedBody.length(), replaceWith.length());
|
||||
|
||||
updatedBody.append(replaceWith);
|
||||
updatedMentions.add(updatedMention);
|
||||
|
||||
bodyIndex = mention.getStart() + mention.getLength();
|
||||
}
|
||||
|
||||
if (bodyIndex < body.length()) {
|
||||
updatedBody.append(body.subSequence(bodyIndex, body.length()));
|
||||
}
|
||||
|
||||
return new UpdatedBodyAndMentions(updatedBody.toString(), updatedMentions);
|
||||
}
|
||||
|
||||
public static @Nullable BodyRangeList mentionsToBodyRangeList(@Nullable List<Mention> mentions) {
|
||||
if (mentions == null || mentions.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
BodyRangeList.Builder builder = BodyRangeList.newBuilder();
|
||||
|
||||
for (Mention mention : mentions) {
|
||||
String uuid = Recipient.resolved(mention.getRecipientId()).requireUuid().toString();
|
||||
builder.addRanges(BodyRangeList.BodyRange.newBuilder()
|
||||
.setMentionUuid(uuid)
|
||||
.setStart(mention.getStart())
|
||||
.setLength(mention.getLength()));
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
public static @NonNull List<Mention> bodyRangeListToMentions(@NonNull Context context, @Nullable byte[] data) {
|
||||
if (data != null) {
|
||||
try {
|
||||
return Stream.of(BodyRangeList.parseFrom(data).getRangesList())
|
||||
.filter(bodyRange -> bodyRange.getAssociatedValueCase() == BodyRangeList.BodyRange.AssociatedValueCase.MENTIONUUID)
|
||||
.map(mention -> {
|
||||
RecipientId id = Recipient.externalPush(context, UuidUtil.parseOrThrow(mention.getMentionUuid()), null, false).getId();
|
||||
return new Mention(id, mention.getStart(), mention.getLength());
|
||||
})
|
||||
.toList();
|
||||
} catch (InvalidProtocolBufferException e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
} else {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
public static @NonNull String getMentionSettingDisplayValue(@NonNull Context context, @NonNull MentionSetting mentionSetting) {
|
||||
switch (mentionSetting) {
|
||||
case ALWAYS_NOTIFY:
|
||||
return context.getString(R.string.GroupMentionSettingDialog_always_notify_me);
|
||||
case DO_NOT_NOTIFY:
|
||||
return context.getString(R.string.GroupMentionSettingDialog_dont_notify_me);
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown mention setting: " + mentionSetting);
|
||||
}
|
||||
|
||||
public static class UpdatedBodyAndMentions {
|
||||
@Nullable private final CharSequence body;
|
||||
@NonNull private final List<Mention> mentions;
|
||||
|
||||
public UpdatedBodyAndMentions(@Nullable CharSequence body, @NonNull List<Mention> mentions) {
|
||||
this.body = body;
|
||||
this.mentions = mentions;
|
||||
}
|
||||
|
||||
public @Nullable CharSequence getBody() {
|
||||
return body;
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentions() {
|
||||
return mentions;
|
||||
}
|
||||
|
||||
@Nullable String getBodyAsString() {
|
||||
return body != null ? body.toString() : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import org.thoughtcrime.securesms.database.documents.Document;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.insights.InsightsConstants;
|
||||
@@ -44,6 +46,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
protected abstract String getTableName();
|
||||
protected abstract String getTypeField();
|
||||
protected abstract String getDateSentColumnName();
|
||||
protected abstract String getDateReceivedColumnName();
|
||||
|
||||
public abstract void markExpireStarted(long messageId);
|
||||
public abstract void markExpireStarted(long messageId, long startTime);
|
||||
@@ -55,6 +58,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
public abstract void markAsSending(long messageId);
|
||||
public abstract void markAsRemoteDelete(long messageId);
|
||||
|
||||
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
|
||||
|
||||
final int getInsecureMessagesSentForThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getReadableDatabase();
|
||||
String[] projection = new String[]{"COUNT(*)"};
|
||||
@@ -140,12 +145,16 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
|
||||
return String.format(Locale.ENGLISH, "(%s OR %s) AND %s", isSent, isReceived, isSecure);
|
||||
}
|
||||
|
||||
public void setReactionsSeen(long threadId) {
|
||||
public void setReactionsSeen(long threadId, long sinceTimestamp) {
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
ContentValues values = new ContentValues();
|
||||
String whereClause = THREAD_ID + " = ? AND " + REACTIONS_UNREAD + " = ?";
|
||||
String[] whereArgs = new String[]{String.valueOf(threadId), "1"};
|
||||
|
||||
if (sinceTimestamp > -1) {
|
||||
whereClause += " AND " + getDateReceivedColumnName() + " <= " + sinceTimestamp;
|
||||
}
|
||||
|
||||
values.put(REACTIONS_UNREAD, 0);
|
||||
values.put(REACTIONS_LAST_SEEN, System.currentTimeMillis());
|
||||
|
||||
|
||||
@@ -47,10 +47,12 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailureList;
|
||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.NotificationMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Quote;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.TrimThreadJob;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
@@ -68,7 +70,9 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo;
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.JsonUtils;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
@@ -109,9 +113,11 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
static final String QUOTE_BODY = "quote_body";
|
||||
static final String QUOTE_ATTACHMENT = "quote_attachment";
|
||||
static final String QUOTE_MISSING = "quote_missing";
|
||||
static final String QUOTE_MENTIONS = "quote_mentions";
|
||||
|
||||
static final String SHARED_CONTACTS = "shared_contacts";
|
||||
static final String LINK_PREVIEWS = "previews";
|
||||
static final String MENTIONS_SELF = "mentions_self";
|
||||
|
||||
public static final String VIEW_ONCE = "reveal_duration";
|
||||
|
||||
@@ -163,6 +169,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
QUOTE_BODY + " TEXT, " +
|
||||
QUOTE_ATTACHMENT + " INTEGER DEFAULT -1, " +
|
||||
QUOTE_MISSING + " INTEGER DEFAULT 0, " +
|
||||
QUOTE_MENTIONS + " BLOB DEFAULT NULL," +
|
||||
SHARED_CONTACTS + " TEXT, " +
|
||||
UNIDENTIFIED + " INTEGER DEFAULT 0, " +
|
||||
LINK_PREVIEWS + " TEXT, " +
|
||||
@@ -170,7 +177,8 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
REACTIONS + " BLOB DEFAULT NULL, " +
|
||||
REACTIONS_UNREAD + " INTEGER DEFAULT 0, " +
|
||||
REACTIONS_LAST_SEEN + " INTEGER DEFAULT -1, " +
|
||||
REMOTE_DELETED + " INTEGER DEFAULT 0);";
|
||||
REMOTE_DELETED + " INTEGER DEFAULT 0, " +
|
||||
MENTIONS_SELF + " INTEGER DEFAULT 0);";
|
||||
|
||||
public static final String[] CREATE_INDEXS = {
|
||||
"CREATE INDEX IF NOT EXISTS mms_thread_id_index ON " + TABLE_NAME + " (" + THREAD_ID + ");",
|
||||
@@ -193,9 +201,9 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
MESSAGE_SIZE, STATUS, TRANSACTION_ID,
|
||||
BODY, PART_COUNT, RECIPIENT_ID, ADDRESS_DEVICE_ID,
|
||||
DELIVERY_RECEIPT_COUNT, READ_RECEIPT_COUNT, MISMATCHED_IDENTITIES, NETWORK_FAILURE, SUBSCRIPTION_ID,
|
||||
EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING,
|
||||
EXPIRES_IN, EXPIRE_STARTED, NOTIFIED, QUOTE_ID, QUOTE_AUTHOR, QUOTE_BODY, QUOTE_ATTACHMENT, QUOTE_MISSING, QUOTE_MENTIONS,
|
||||
SHARED_CONTACTS, LINK_PREVIEWS, UNIDENTIFIED, VIEW_ONCE, REACTIONS, REACTIONS_UNREAD, REACTIONS_LAST_SEEN,
|
||||
REMOTE_DELETED,
|
||||
REMOTE_DELETED, MENTIONS_SELF,
|
||||
"json_group_array(json_object(" +
|
||||
"'" + AttachmentDatabase.ROW_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.ROW_ID + ", " +
|
||||
"'" + AttachmentDatabase.UNIQUE_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UNIQUE_ID + ", " +
|
||||
@@ -209,6 +217,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
"'" + AttachmentDatabase.CONTENT_LOCATION + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CONTENT_LOCATION + ", " +
|
||||
"'" + AttachmentDatabase.FAST_PREFLIGHT_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.FAST_PREFLIGHT_ID + "," +
|
||||
"'" + AttachmentDatabase.VOICE_NOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VOICE_NOTE + "," +
|
||||
"'" + AttachmentDatabase.BORDERLESS + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BORDERLESS + "," +
|
||||
"'" + AttachmentDatabase.WIDTH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.WIDTH + "," +
|
||||
"'" + AttachmentDatabase.HEIGHT + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.HEIGHT + "," +
|
||||
"'" + AttachmentDatabase.QUOTE + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.QUOTE + ", " +
|
||||
@@ -247,6 +256,11 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
return DATE_SENT;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getDateReceivedColumnName() {
|
||||
return DATE_RECEIVED;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String getTypeField() {
|
||||
return MESSAGE_BOX;
|
||||
@@ -444,6 +458,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
return rawQuery(RAW_ID_WHERE, new String[] {messageId + ""});
|
||||
}
|
||||
|
||||
@Override
|
||||
public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
|
||||
try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) {
|
||||
MessageRecord record = new Reader(cursor).getNext();
|
||||
@@ -456,6 +471,11 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
public Reader getMessages(Collection<Long> messageIds) {
|
||||
String ids = TextUtils.join(",", messageIds);
|
||||
return readerFor(rawQuery(MmsDatabase.TABLE_NAME + "." + MmsDatabase.ID + " IN (" + ids + ")", null));
|
||||
}
|
||||
|
||||
public Reader getExpireStartedMessages() {
|
||||
String where = EXPIRE_STARTED + " > 0";
|
||||
return readerFor(rawQuery(where, null));
|
||||
@@ -630,12 +650,14 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(id)});
|
||||
}
|
||||
|
||||
|
||||
public List<MarkedMessageInfo> setMessagesRead(long threadId) {
|
||||
return setMessagesRead(THREAD_ID + " = ? AND " + READ + " = 0", new String[] {String.valueOf(threadId)});
|
||||
public List<MarkedMessageInfo> setMessagesReadSince(long threadId, long sinceTimestamp) {
|
||||
if (sinceTimestamp == -1) {
|
||||
return setMessagesRead(THREAD_ID + " = ? AND " + READ + " = 0", new String[] {String.valueOf(threadId)});
|
||||
} else {
|
||||
return setMessagesRead(THREAD_ID + " = ? AND " + READ + " = 0 AND " + DATE_RECEIVED + " <= ?", new String[]{String.valueOf(threadId), String.valueOf(sinceTimestamp)});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public List<MarkedMessageInfo> setEntireThreadRead(long threadId) {
|
||||
return setMessagesRead(THREAD_ID + " = ?", new String[] {String.valueOf(threadId)});
|
||||
}
|
||||
@@ -725,6 +747,36 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
return expiring;
|
||||
}
|
||||
|
||||
public @Nullable Pair<RecipientId, Long> getOldestUnreadMentionDetails(long threadId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String[] projection = new String[]{RECIPIENT_ID,DATE_RECEIVED};
|
||||
String selection = THREAD_ID + " = ? AND " + READ + " = 0 AND " + MENTIONS_SELF + " = 1";
|
||||
String[] args = SqlUtil.buildArgs(threadId);
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, projection, selection, args, null, null, DATE_RECEIVED + " ASC", "1")) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return new Pair<>(RecipientId.from(CursorUtil.requireString(cursor, RECIPIENT_ID)), CursorUtil.requireLong(cursor, DATE_RECEIVED));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public int getUnreadMentionCount(long threadId) {
|
||||
SQLiteDatabase database = databaseHelper.getReadableDatabase();
|
||||
String[] projection = new String[]{"COUNT(*)"};
|
||||
String selection = THREAD_ID + " = ? AND " + READ + " = 0 AND " + MENTIONS_SELF + " = 1";
|
||||
String[] args = SqlUtil.buildArgs(threadId);
|
||||
|
||||
try (Cursor cursor = database.query(TABLE_NAME, projection, selection, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return cursor.getInt(0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void updateMessageBody(long messageId, String body) {
|
||||
long type = 0;
|
||||
|
||||
@@ -796,6 +848,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
throws MmsException, NoSuchMessageException
|
||||
{
|
||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context);
|
||||
Cursor cursor = null;
|
||||
|
||||
try {
|
||||
@@ -803,6 +856,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
|
||||
if (cursor != null && cursor.moveToNext()) {
|
||||
List<DatabaseAttachment> associatedAttachments = attachmentDatabase.getAttachmentsForMessage(messageId);
|
||||
List<Mention> mentions = mentionDatabase.getMentionsForMessage(messageId);
|
||||
|
||||
long outboxType = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX));
|
||||
String body = cursor.getString(cursor.getColumnIndexOrThrow(BODY));
|
||||
@@ -821,6 +875,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(QUOTE_BODY));
|
||||
boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(QUOTE_MISSING)) == 1;
|
||||
List<Attachment> quoteAttachments = Stream.of(associatedAttachments).filter(Attachment::isQuote).map(a -> (Attachment)a).toList();
|
||||
List<Mention> quoteMentions = parseQuoteMentions(cursor);
|
||||
List<Contact> contacts = getSharedContacts(cursor, associatedAttachments);
|
||||
Set<Attachment> contactAttachments = new HashSet<>(Stream.of(contacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList());
|
||||
List<LinkPreview> previews = getLinkPreviews(cursor, associatedAttachments);
|
||||
@@ -837,7 +892,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
QuoteModel quote = null;
|
||||
|
||||
if (quoteId > 0 && quoteAuthor > 0 && (!TextUtils.isEmpty(quoteText) || !quoteAttachments.isEmpty())) {
|
||||
quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments);
|
||||
quote = new QuoteModel(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteAttachments, quoteMentions);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(mismatchDocument)) {
|
||||
@@ -857,12 +912,12 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
|
||||
if (body != null && (Types.isGroupQuit(outboxType) || Types.isGroupUpdate(outboxType))) {
|
||||
return new OutgoingGroupUpdateMessage(recipient, new MessageGroupContext(body, Types.isGroupV2(outboxType)), attachments, timestamp, 0, false, quote, contacts, previews);
|
||||
return new OutgoingGroupUpdateMessage(recipient, new MessageGroupContext(body, Types.isGroupV2(outboxType)), attachments, timestamp, 0, false, quote, contacts, previews, mentions);
|
||||
} else if (Types.isExpirationTimerUpdate(outboxType)) {
|
||||
return new OutgoingExpirationUpdateMessage(recipient, timestamp, expiresIn);
|
||||
}
|
||||
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, networkFailures, mismatches);
|
||||
OutgoingMediaMessage message = new OutgoingMediaMessage(recipient, body, attachments, timestamp, subscriptionId, expiresIn, viewOnce, distributionType, quote, contacts, previews, mentions, networkFailures, mismatches);
|
||||
|
||||
if (Types.isSecureType(outboxType)) {
|
||||
return new OutgoingSecureMediaMessage(message);
|
||||
@@ -991,10 +1046,15 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
|
||||
if (retrieved.getQuote() != null) {
|
||||
contentValues.put(QUOTE_ID, retrieved.getQuote().getId());
|
||||
contentValues.put(QUOTE_BODY, retrieved.getQuote().getText());
|
||||
contentValues.put(QUOTE_BODY, retrieved.getQuote().getText().toString());
|
||||
contentValues.put(QUOTE_AUTHOR, retrieved.getQuote().getAuthor().serialize());
|
||||
contentValues.put(QUOTE_MISSING, retrieved.getQuote().isOriginalMissing() ? 1 : 0);
|
||||
|
||||
BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(retrieved.getQuote().getMentions());
|
||||
if (mentionsList != null) {
|
||||
contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray());
|
||||
}
|
||||
|
||||
quoteAttachments = retrieved.getQuote().getAttachments();
|
||||
}
|
||||
|
||||
@@ -1003,7 +1063,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
return Optional.absent();
|
||||
}
|
||||
|
||||
long messageId = insertMediaMessage(retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), contentValues, null);
|
||||
long messageId = insertMediaMessage(threadId, retrieved.getBody(), retrieved.getAttachments(), quoteAttachments, retrieved.getSharedContacts(), retrieved.getLinkPreviews(), retrieved.getMentions(), contentValues, null);
|
||||
|
||||
if (!Types.isExpirationTimerUpdate(mailbox)) {
|
||||
DatabaseFactory.getThreadDatabase(context).incrementUnread(threadId, 1);
|
||||
@@ -1149,15 +1209,23 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
List<Attachment> quoteAttachments = new LinkedList<>();
|
||||
|
||||
if (message.getOutgoingQuote() != null) {
|
||||
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getOutgoingQuote().getText(), message.getOutgoingQuote().getMentions());
|
||||
|
||||
contentValues.put(QUOTE_ID, message.getOutgoingQuote().getId());
|
||||
contentValues.put(QUOTE_AUTHOR, message.getOutgoingQuote().getAuthor().serialize());
|
||||
contentValues.put(QUOTE_BODY, message.getOutgoingQuote().getText());
|
||||
contentValues.put(QUOTE_BODY, updated.getBodyAsString());
|
||||
contentValues.put(QUOTE_MISSING, message.getOutgoingQuote().isOriginalMissing() ? 1 : 0);
|
||||
|
||||
BodyRangeList mentionsList = MentionUtil.mentionsToBodyRangeList(updated.getMentions());
|
||||
if (mentionsList != null) {
|
||||
contentValues.put(QUOTE_MENTIONS, mentionsList.toByteArray());
|
||||
}
|
||||
|
||||
quoteAttachments.addAll(message.getOutgoingQuote().getAttachments());
|
||||
}
|
||||
|
||||
long messageId = insertMediaMessage(message.getBody(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), contentValues, insertListener);
|
||||
MentionUtil.UpdatedBodyAndMentions updatedBodyAndMentions = MentionUtil.updateBodyAndMentionsWithPlaceholders(message.getBody(), message.getMentions());
|
||||
long messageId = insertMediaMessage(threadId, updatedBodyAndMentions.getBodyAsString(), message.getAttachments(), quoteAttachments, message.getSharedContacts(), message.getLinkPreviews(), updatedBodyAndMentions.getMentions(), contentValues, insertListener);
|
||||
|
||||
if (message.getRecipient().isGroup()) {
|
||||
OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (message instanceof OutgoingGroupUpdateMessage) ? (OutgoingGroupUpdateMessage) message : null;
|
||||
@@ -1188,17 +1256,22 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
return messageId;
|
||||
}
|
||||
|
||||
private long insertMediaMessage(@Nullable String body,
|
||||
private long insertMediaMessage(long threadId,
|
||||
@Nullable String body,
|
||||
@NonNull List<Attachment> attachments,
|
||||
@NonNull List<Attachment> quoteAttachments,
|
||||
@NonNull List<Contact> sharedContacts,
|
||||
@NonNull List<LinkPreview> linkPreviews,
|
||||
@NonNull List<Mention> mentions,
|
||||
@NonNull ContentValues contentValues,
|
||||
@Nullable SmsDatabase.InsertListener insertListener)
|
||||
throws MmsException
|
||||
{
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
AttachmentDatabase partsDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
SQLiteDatabase db = databaseHelper.getWritableDatabase();
|
||||
AttachmentDatabase partsDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
MentionDatabase mentionDatabase = DatabaseFactory.getMentionDatabase(context);
|
||||
|
||||
boolean mentionsSelf = Stream.of(mentions).filter(m -> Recipient.resolved(m.getRecipientId()).isLocalNumber()).findFirst().isPresent();
|
||||
|
||||
List<Attachment> allAttachments = new LinkedList<>();
|
||||
List<Attachment> contactAttachments = Stream.of(sharedContacts).map(Contact::getAvatarAttachment).filter(a -> a != null).toList();
|
||||
@@ -1210,11 +1283,14 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
|
||||
contentValues.put(BODY, body);
|
||||
contentValues.put(PART_COUNT, allAttachments.size());
|
||||
contentValues.put(MENTIONS_SELF, mentionsSelf ? 1 : 0);
|
||||
|
||||
db.beginTransaction();
|
||||
try {
|
||||
long messageId = db.insert(TABLE_NAME, null, contentValues);
|
||||
|
||||
mentionDatabase.insert(threadId, messageId, mentions);
|
||||
|
||||
Map<Attachment, AttachmentId> insertedAttachments = partsDatabase.insertAttachmentsForMessage(messageId, allAttachments, quoteAttachments);
|
||||
String serializedContacts = getSerializedSharedContacts(insertedAttachments, sharedContacts);
|
||||
String serializedPreviews = getSerializedLinkPreviews(insertedAttachments, linkPreviews);
|
||||
@@ -1459,6 +1535,12 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull List<Mention> parseQuoteMentions(Cursor cursor) {
|
||||
byte[] raw = cursor.getBlob(cursor.getColumnIndexOrThrow(QUOTE_MENTIONS));
|
||||
|
||||
return MentionUtil.bodyRangeListToMentions(context, raw);
|
||||
}
|
||||
|
||||
public void beginTransaction() {
|
||||
databaseHelper.getWritableDatabase().beginTransaction();
|
||||
}
|
||||
@@ -1533,6 +1615,16 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
public MessageRecord getCurrent() {
|
||||
SlideDeck slideDeck = new SlideDeck(context, message.getAttachments());
|
||||
|
||||
CharSequence quoteText = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getText() : null;
|
||||
List<Mention> quoteMentions = message.getOutgoingQuote() != null ? message.getOutgoingQuote().getMentions() : Collections.emptyList();
|
||||
|
||||
if (quoteText != null && !quoteMentions.isEmpty()) {
|
||||
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions);
|
||||
|
||||
quoteText = updated.getBody();
|
||||
quoteMentions = updated.getMentions();
|
||||
}
|
||||
|
||||
return new MediaMmsMessageRecord(id,
|
||||
message.getRecipient(),
|
||||
message.getRecipient(),
|
||||
@@ -1555,14 +1647,16 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
message.getOutgoingQuote() != null ?
|
||||
new Quote(message.getOutgoingQuote().getId(),
|
||||
message.getOutgoingQuote().getAuthor(),
|
||||
message.getOutgoingQuote().getText(),
|
||||
quoteText,
|
||||
message.getOutgoingQuote().isOriginalMissing(),
|
||||
new SlideDeck(context, message.getOutgoingQuote().getAttachments())) :
|
||||
new SlideDeck(context, message.getOutgoingQuote().getAttachments()),
|
||||
quoteMentions) :
|
||||
null,
|
||||
message.getSharedContacts(),
|
||||
message.getLinkPreviews(),
|
||||
false,
|
||||
Collections.emptyList(),
|
||||
false,
|
||||
false);
|
||||
}
|
||||
}
|
||||
@@ -1656,6 +1750,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
boolean isViewOnce = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) == 1;
|
||||
boolean remoteDelete = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.REMOTE_DELETED)) == 1;
|
||||
List<ReactionRecord> reactions = parseReactions(cursor);
|
||||
boolean mentionsSelf = CursorUtil.requireBoolean(cursor, MENTIONS_SELF);
|
||||
|
||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0;
|
||||
@@ -1677,7 +1772,7 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
threadId, body, slideDeck, partCount, box, mismatches,
|
||||
networkFailures, subscriptionId, expiresIn, expireStarted,
|
||||
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, reactions,
|
||||
remoteDelete);
|
||||
remoteDelete, mentionsSelf);
|
||||
}
|
||||
|
||||
private List<IdentityKeyMismatch> getMismatchedIdentities(String document) {
|
||||
@@ -1715,14 +1810,22 @@ public class MmsDatabase extends MessagingDatabase {
|
||||
private @Nullable Quote getQuote(@NonNull Cursor cursor) {
|
||||
long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_ID));
|
||||
long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_AUTHOR));
|
||||
String quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_BODY));
|
||||
CharSequence quoteText = cursor.getString(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_BODY));
|
||||
boolean quoteMissing = cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.QUOTE_MISSING)) == 1;
|
||||
List<Mention> quoteMentions = parseQuoteMentions(cursor);
|
||||
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachment(cursor);
|
||||
List<? extends Attachment> quoteAttachments = Stream.of(attachments).filter(Attachment::isQuote).toList();
|
||||
SlideDeck quoteDeck = new SlideDeck(context, quoteAttachments);
|
||||
|
||||
if (quoteId > 0 && quoteAuthor > 0) {
|
||||
return new Quote(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteDeck);
|
||||
if (quoteText != null && !quoteMentions.isEmpty()) {
|
||||
MentionUtil.UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, quoteText, quoteMentions);
|
||||
|
||||
quoteText = updated.getBody();
|
||||
quoteMentions = updated.getMentions();
|
||||
}
|
||||
|
||||
return new Quote(quoteId, RecipientId.from(quoteAuthor), quoteText, quoteMissing, quoteDeck, quoteMentions);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user