Merge pull request #1602 from oxen-io/release/1.19.0

Release/1.19.0
This commit is contained in:
ThomasSession
2024-08-09 13:41:15 +10:00
committed by GitHub
460 changed files with 504984 additions and 305317 deletions

View File

@@ -31,8 +31,8 @@ configurations.all {
exclude module: "commons-logging"
}
def canonicalVersionCode = 376
def canonicalVersionName = "1.18.6"
def canonicalVersionCode = 379
def canonicalVersionName = "1.19.0"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
@@ -263,7 +263,7 @@ dependencies {
implementation 'androidx.fragment:fragment-ktx:1.5.3'
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.work:work-runtime-ktx:2.7.1"
playImplementation ("com.google.firebase:firebase-messaging:18.0.0") {
playImplementation ("com.google.firebase:firebase-messaging:24.0.0") {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
@@ -271,7 +271,7 @@ dependencies {
if (project.hasProperty('huawei')) huaweiImplementation 'com.huawei.hms:push:6.7.0.300'
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
implementation 'org.conscrypt:conscrypt-android:2.0.0'
implementation 'org.conscrypt:conscrypt-android:2.5.2'
implementation 'org.signal:aesgcmprovider:0.0.3'
implementation 'org.webrtc:google-webrtc:1.0.32006'
implementation "me.leolin:ShortcutBadger:1.1.16"
@@ -322,6 +322,7 @@ dependencies {
implementation "com.google.protobuf:protobuf-java:$protobufVersion"
implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonDatabindVersion"
implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
implementation "com.squareup.phrase:phrase:$phraseVersion"
implementation 'app.cash.copper:copper-flow:1.0.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
@@ -375,14 +376,24 @@ dependencies {
implementation 'com.github.bumptech.glide:compose:1.0.0-alpha.5'
implementation 'androidx.compose.ui:ui:1.5.2'
implementation 'androidx.compose.ui:ui-tooling:1.5.2'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-pager-indicators:0.33.1-alpha"
implementation "androidx.compose.runtime:runtime-livedata:1.5.2"
implementation "androidx.compose.ui:ui:$composeVersion"
implementation "androidx.compose.animation:animation:$composeVersion"
implementation "androidx.compose.ui:ui-tooling:$composeVersion"
implementation "androidx.compose.runtime:runtime-livedata:$composeVersion"
implementation "androidx.compose.foundation:foundation-layout:$composeVersion"
implementation "androidx.compose.material3:material3:1.2.1"
androidTestImplementation "androidx.compose.ui:ui-test-junit4-android:$composeVersion"
debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion"
implementation 'androidx.compose.foundation:foundation-layout:1.5.2'
implementation 'androidx.compose.material:material:1.5.2'
implementation "com.google.accompanist:accompanist-themeadapter-appcompat:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-permissions:0.33.1-alpha"
implementation "com.google.accompanist:accompanist-drawablepainter:0.33.1-alpha"
implementation "androidx.camera:camera-camera2:1.3.2"
implementation "androidx.camera:camera-lifecycle:1.3.2"
implementation "androidx.camera:camera-view:1.3.2"
implementation "com.google.mlkit:barcode-scanning:17.2.0"
}
static def getLastCommitTimestamp() {

View File

@@ -22,6 +22,8 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import com.adevinta.android.barista.interaction.PermissionGranter
import network.loki.messenger.util.InputBarButtonDrawableMatcher.Companion.inputButtonWithDrawable
import org.hamcrest.Matcher
@@ -49,9 +51,14 @@ class HomeActivityTests {
private val activityMonitor = Instrumentation.ActivityMonitor(ConversationActivityV2::class.java.name, null, false)
private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Before
fun setUp() {
InstrumentationRegistry.getInstrumentation().addMonitor(activityMonitor)
}
@After
@@ -72,25 +79,34 @@ class HomeActivityTests {
onView(isRoot()).perform(waitFor(500))
}
private fun objectFromDesc(id: Int) = device.findObject(By.desc(context.getString(id)))
private fun setupLoggedInState(hasViewedSeed: Boolean = false) {
// landing activity
onView(withId(R.id.registerButton)).perform(ViewActions.click())
// session ID - register activity
onView(withId(R.id.registerButton)).perform(ViewActions.click())
objectFromDesc(R.string.onboardingAccountCreate).click()
// display name selection
onView(withId(R.id.displayNameEditText)).perform(ViewActions.typeText("test-user123"))
onView(withId(R.id.registerButton)).perform(ViewActions.click())
objectFromDesc(R.string.displayNameEnter).click()
device.pressKeyCode(65)
device.pressKeyCode(66)
device.pressKeyCode(67)
// Continue with display name
objectFromDesc(R.string.continue_2).click()
// Continue with default push notification setting
objectFromDesc(R.string.continue_2).click()
// PN select
if (hasViewedSeed) {
// has viewed seed is set to false after register activity
TextSecurePreferences.setHasViewedSeed(InstrumentationRegistry.getInstrumentation().targetContext, true)
}
onView(withId(R.id.backgroundPollingOptionView)).perform(ViewActions.click())
onView(withId(R.id.registerButton)).perform(ViewActions.click())
// allow notification permission
PermissionGranter.allowPermissionsIfNeeded(Manifest.permission.POST_NOTIFICATIONS)
}
private fun goToMyChat() {
onView(withId(R.id.newConversationButton)).perform(ViewActions.click())
onView(withId(R.id.createPrivateChatButton)).perform(ViewActions.click())
@@ -111,8 +127,8 @@ class HomeActivityTests {
@Test
fun testLaunches_dismiss_seedView() {
setupLoggedInState()
onView(allOf(withId(R.id.button), isDescendantOfA(withId(R.id.seedReminderView)))).perform(ViewActions.click())
onView(withId(R.id.copyButton)).perform(ViewActions.click())
objectFromDesc(R.string.continue_2).click()
objectFromDesc(R.string.copy).click()
pressBack()
onView(withId(R.id.seedReminderView)).check(matches(not(isDisplayed())))
}
@@ -133,7 +149,7 @@ class HomeActivityTests {
fun testChat_withSelf() {
setupLoggedInState()
goToMyChat()
TextSecurePreferences.setLinkPreviewsEnabled(InstrumentationRegistry.getInstrumentation().targetContext, true)
TextSecurePreferences.setLinkPreviewsEnabled(context, true)
sendMessage("howdy")
sendMessage("test")
// tests url rewriter doesn't crash

View File

@@ -39,7 +39,7 @@ class LibSessionTests {
private fun randomSeedBytes() = (0 until 16).map { Random.nextInt(UByte.MAX_VALUE.toInt()).toByte() }
private fun randomKeyPair() = KeyPairUtilities.generate(randomSeedBytes().toByteArray())
private fun randomSessionId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private fun randomAccountId() = randomKeyPair().x25519KeyPair.hexEncodedPublicKey
private var fakeHashI = 0
private val nextFakeHash: String
@@ -102,7 +102,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage)
app.storage = storageSpy
val newContactId = randomSessionId()
val newContactId = randomAccountId()
val singleContact = Contact(
id = newContactId,
approved = true,
@@ -123,7 +123,7 @@ class LibSessionTests {
val storageSpy = spy(app.storage)
app.storage = storageSpy
val randomRecipient = randomSessionId()
val randomRecipient = randomAccountId()
val newContact = Contact(
id = randomRecipient,
approved = true,
@@ -158,7 +158,7 @@ class LibSessionTests {
app.storage = storageSpy
// Initial state
val randomRecipient = randomSessionId()
val randomRecipient = randomAccountId()
val currentContact = Contact(
id = randomRecipient,
approved = true,

View File

@@ -136,29 +136,29 @@ class SodiumUtilitiesTest {
}
@Test
fun sessionIdSuccess() {
val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", serverPublicKey)
fun accountIdSuccess() {
val result = SodiumUtilities.accountId("05$publicKey", "15$blindedPublicKey", serverPublicKey)
assertTrue(result)
}
@Test
fun sessionIdFailureInvalidSessionId() {
val result = SodiumUtilities.sessionId("AB$publicKey", "15$blindedPublicKey", serverPublicKey)
fun accountIdFailureInvalidAccountId() {
val result = SodiumUtilities.accountId("AB$publicKey", "15$blindedPublicKey", serverPublicKey)
assertFalse(result)
}
@Test
fun sessionIdFailureInvalidBlindedId() {
val result = SodiumUtilities.sessionId("05$publicKey", "AB$blindedPublicKey", serverPublicKey)
fun accountIdFailureInvalidBlindedId() {
val result = SodiumUtilities.accountId("05$publicKey", "AB$blindedPublicKey", serverPublicKey)
assertFalse(result)
}
@Test
fun sessionIdFailureBlindingFactor() {
val result = SodiumUtilities.sessionId("05$publicKey", "15$blindedPublicKey", "Test")
fun accountIdFailureBlindingFactor() {
val result = SodiumUtilities.accountId("05$publicKey", "15$blindedPublicKey", "Test")
assertFalse(result)
}

View File

@@ -60,6 +60,7 @@
<uses-permission android:name="android.permission.RAISED_THREAD_PRIORITY" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" tools:node="remove"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<queries>
<intent>
@@ -99,25 +100,26 @@
android:value="false" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.LandingActivity"
android:name="org.thoughtcrime.securesms.onboarding.landing.LandingActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.RegisterActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.LinkDeviceActivity"
android:name="org.thoughtcrime.securesms.onboarding.loadaccount.LoadAccountActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.DisplayNameActivity"
android:name="org.thoughtcrime.securesms.onboarding.loading.LoadingActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.PNModeActivity"
android:name="org.thoughtcrime.securesms.onboarding.pickname.PickDisplayNameActivity"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.messagenotifications.MessageNotificationsActivity"
android:screenOrientation="portrait"
android:theme="@style/Theme.Session.DayNight.FlatActionBar" />
<activity
@@ -152,7 +154,7 @@
android:label="@string/activity_edit_closed_group_title"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.onboarding.SeedActivity"
android:name="org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity"
android:screenOrientation="portrait" />
<activity
android:name="org.thoughtcrime.securesms.contacts.SelectContactsActivity"

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
226074,en,AF,Africa,UG,Uganda,0
239880,en,AF,Africa,CF,"Central African Republic",0
241170,en,AF,Africa,SC,Seychelles,0
248816,en,AS,Asia,JO,"Hashemite Kingdom of Jordan",0
248816,en,AS,Asia,JO,Jordan,0
272103,en,AS,Asia,LB,Lebanon,0
285570,en,AS,Asia,KW,Kuwait,0
286963,en,AS,Asia,OM,Oman,0
@@ -23,7 +23,7 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
290291,en,AS,Asia,BH,Bahrain,0
290557,en,AS,Asia,AE,"United Arab Emirates",0
294640,en,AS,Asia,IL,Israel,0
298795,en,AS,Asia,TR,Turkey,0
298795,en,AS,Asia,TR,Türkiye,0
337996,en,AF,Africa,ET,Ethiopia,0
338010,en,AF,Africa,ER,Eritrea,0
357994,en,AF,Africa,EG,Egypt,0
@@ -33,13 +33,13 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
453733,en,EU,Europe,EE,Estonia,1
458258,en,EU,Europe,LV,Latvia,1
587116,en,AS,Asia,AZ,Azerbaijan,0
597427,en,EU,Europe,LT,"Republic of Lithuania",1
597427,en,EU,Europe,LT,Lithuania,1
607072,en,EU,Europe,SJ,"Svalbard and Jan Mayen",0
614540,en,AS,Asia,GE,Georgia,0
617790,en,EU,Europe,MD,"Republic of Moldova",0
617790,en,EU,Europe,MD,Moldova,0
630336,en,EU,Europe,BY,Belarus,0
660013,en,EU,Europe,FI,Finland,1
661882,en,EU,Europe,AX,"Åland",1
661882,en,EU,Europe,AX,"Åland Islands",1
690791,en,EU,Europe,UA,Ukraine,0
718075,en,EU,Europe,MK,"North Macedonia",0
719819,en,EU,Europe,HU,Hungary,1
@@ -77,8 +77,8 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
1522867,en,AS,Asia,KZ,Kazakhstan,0
1527747,en,AS,Asia,KG,Kyrgyzstan,0
1546748,en,AN,Antarctica,TF,"French Southern Territories",0
1547314,en,AN,Antarctica,HM,"Heard Island and McDonald Islands",0
1547376,en,AS,Asia,CC,"Cocos [Keeling] Islands",0
1547314,en,AN,Antarctica,HM,"Heard and McDonald Islands",0
1547376,en,AS,Asia,CC,"Cocos (Keeling) Islands",0
1559582,en,OC,Oceania,PW,Palau,0
1562822,en,AS,Asia,VN,Vietnam,0
1605651,en,AS,Asia,TH,Thailand,0
@@ -97,7 +97,7 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
1873107,en,AS,Asia,KP,"North Korea",0
1880251,en,AS,Asia,SG,Singapore,0
1899402,en,OC,Oceania,CK,"Cook Islands",0
1966436,en,OC,Oceania,TL,"East Timor",0
1966436,en,OC,Oceania,TL,Timor-Leste,0
2017370,en,EU,Europe,RU,Russia,0
2029969,en,AS,Asia,MN,Mongolia,0
2077456,en,OC,Oceania,AU,Australia,0
@@ -131,7 +131,7 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
2400553,en,AF,Africa,GA,Gabon,0
2403846,en,AF,Africa,SL,"Sierra Leone",0
2410758,en,AF,Africa,ST,"São Tomé and Príncipe",0
2411586,en,EU,Europe,GI,Gibraltar,1
2411586,en,EU,Europe,GI,Gibraltar,0
2413451,en,AF,Africa,GM,Gambia,0
2420477,en,AF,Africa,GN,Guinea,0
2434508,en,AF,Africa,TD,Chad,0
@@ -146,10 +146,10 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
2622320,en,EU,Europe,FO,"Faroe Islands",0
2623032,en,EU,Europe,DK,Denmark,1
2629691,en,EU,Europe,IS,Iceland,0
2635167,en,EU,Europe,GB,"United Kingdom",1
2635167,en,EU,Europe,GB,"United Kingdom",0
2658434,en,EU,Europe,CH,Switzerland,0
2661886,en,EU,Europe,SE,Sweden,1
2750405,en,EU,Europe,NL,Netherlands,1
2750405,en,EU,Europe,NL,"The Netherlands",1
2782113,en,EU,Europe,AT,Austria,1
2802361,en,EU,Europe,BE,Belgium,1
2921044,en,EU,Europe,DE,Germany,1
@@ -203,7 +203,7 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
3576916,en,NA,"North America",TC,"Turks and Caicos Islands",0
3577279,en,NA,"North America",AW,Aruba,0
3577718,en,NA,"North America",VG,"British Virgin Islands",0
3577815,en,NA,"North America",VC,"Saint Vincent and the Grenadines",0
3577815,en,NA,"North America",VC,"St Vincent and Grenadines",0
3578097,en,NA,"North America",MS,Montserrat,0
3578421,en,NA,"North America",MF,"Saint Martin",1
3578476,en,NA,"North America",BL,"Saint Barthélemy",0
@@ -238,7 +238,7 @@ geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_na
4043988,en,OC,Oceania,GU,Guam,0
4566966,en,NA,"North America",PR,"Puerto Rico",0
4796775,en,NA,"North America",VI,"U.S. Virgin Islands",0
5854968,en,OC,Oceania,UM,"U.S. Minor Outlying Islands",0
5854968,en,OC,Oceania,UM,"U.S. Outlying Islands",0
5880801,en,OC,Oceania,AS,"American Samoa",0
6251999,en,NA,"North America",CA,Canada,0
6252001,en,NA,"North America",US,"United States",0
1 geoname_id locale_code continent_code continent_name country_iso_code country_name is_in_european_union
15 226074 en AF Africa UG Uganda 0
16 239880 en AF Africa CF Central African Republic 0
17 241170 en AF Africa SC Seychelles 0
18 248816 en AS Asia JO Hashemite Kingdom of Jordan Jordan 0
19 272103 en AS Asia LB Lebanon 0
20 285570 en AS Asia KW Kuwait 0
21 286963 en AS Asia OM Oman 0
23 290291 en AS Asia BH Bahrain 0
24 290557 en AS Asia AE United Arab Emirates 0
25 294640 en AS Asia IL Israel 0
26 298795 en AS Asia TR Turkey Türkiye 0
27 337996 en AF Africa ET Ethiopia 0
28 338010 en AF Africa ER Eritrea 0
29 357994 en AF Africa EG Egypt 0
33 453733 en EU Europe EE Estonia 1
34 458258 en EU Europe LV Latvia 1
35 587116 en AS Asia AZ Azerbaijan 0
36 597427 en EU Europe LT Republic of Lithuania Lithuania 1
37 607072 en EU Europe SJ Svalbard and Jan Mayen 0
38 614540 en AS Asia GE Georgia 0
39 617790 en EU Europe MD Republic of Moldova Moldova 0
40 630336 en EU Europe BY Belarus 0
41 660013 en EU Europe FI Finland 1
42 661882 en EU Europe AX Åland Åland Islands 1
43 690791 en EU Europe UA Ukraine 0
44 718075 en EU Europe MK North Macedonia 0
45 719819 en EU Europe HU Hungary 1
77 1522867 en AS Asia KZ Kazakhstan 0
78 1527747 en AS Asia KG Kyrgyzstan 0
79 1546748 en AN Antarctica TF French Southern Territories 0
80 1547314 en AN Antarctica HM Heard Island and McDonald Islands Heard and McDonald Islands 0
81 1547376 en AS Asia CC Cocos [Keeling] Islands Cocos (Keeling) Islands 0
82 1559582 en OC Oceania PW Palau 0
83 1562822 en AS Asia VN Vietnam 0
84 1605651 en AS Asia TH Thailand 0
97 1873107 en AS Asia KP North Korea 0
98 1880251 en AS Asia SG Singapore 0
99 1899402 en OC Oceania CK Cook Islands 0
100 1966436 en OC Oceania TL East Timor Timor-Leste 0
101 2017370 en EU Europe RU Russia 0
102 2029969 en AS Asia MN Mongolia 0
103 2077456 en OC Oceania AU Australia 0
131 2400553 en AF Africa GA Gabon 0
132 2403846 en AF Africa SL Sierra Leone 0
133 2410758 en AF Africa ST São Tomé and Príncipe 0
134 2411586 en EU Europe GI Gibraltar 1 0
135 2413451 en AF Africa GM Gambia 0
136 2420477 en AF Africa GN Guinea 0
137 2434508 en AF Africa TD Chad 0
146 2622320 en EU Europe FO Faroe Islands 0
147 2623032 en EU Europe DK Denmark 1
148 2629691 en EU Europe IS Iceland 0
149 2635167 en EU Europe GB United Kingdom 1 0
150 2658434 en EU Europe CH Switzerland 0
151 2661886 en EU Europe SE Sweden 1
152 2750405 en EU Europe NL Netherlands The Netherlands 1
153 2782113 en EU Europe AT Austria 1
154 2802361 en EU Europe BE Belgium 1
155 2921044 en EU Europe DE Germany 1
203 3576916 en NA North America TC Turks and Caicos Islands 0
204 3577279 en NA North America AW Aruba 0
205 3577718 en NA North America VG British Virgin Islands 0
206 3577815 en NA North America VC Saint Vincent and the Grenadines St Vincent and Grenadines 0
207 3578097 en NA North America MS Montserrat 0
208 3578421 en NA North America MF Saint Martin 1
209 3578476 en NA North America BL Saint Barthélemy 0
238 4043988 en OC Oceania GU Guam 0
239 4566966 en NA North America PR Puerto Rico 0
240 4796775 en NA North America VI U.S. Virgin Islands 0
241 5854968 en OC Oceania UM U.S. Minor Outlying Islands U.S. Outlying Islands 0
242 5880801 en OC Oceania AS American Samoa 0
243 6251999 en NA North America CA Canada 0
244 6252001 en NA North America US United States 0

View File

@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
import static nl.komponents.kovenant.android.KovenantAndroid.startKovenant;
import static nl.komponents.kovenant.android.KovenantAndroid.stopKovenant;
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import android.content.Intent;
@@ -85,6 +86,7 @@ import org.thoughtcrime.securesms.sskenvironment.ProfileManager;
import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager;
import org.thoughtcrime.securesms.sskenvironment.TypingStatusRepository;
import org.thoughtcrime.securesms.util.Broadcaster;
import org.thoughtcrime.securesms.util.VersionDataFetcher;
import org.thoughtcrime.securesms.util.dynamiclanguage.LocaleParseHelper;
import org.thoughtcrime.securesms.webrtc.CallMessageProcessor;
import org.webrtc.PeerConnectionFactory;
@@ -109,7 +111,6 @@ import javax.inject.Inject;
import dagger.hilt.EntryPoints;
import dagger.hilt.android.HiltAndroidApp;
import kotlin.Unit;
import kotlinx.coroutines.Job;
import network.loki.messenger.BuildConfig;
import network.loki.messenger.libsession_util.ConfigBase;
import network.loki.messenger.libsession_util.UserProfile;
@@ -137,7 +138,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
public MessageNotifier messageNotifier = null;
public Poller poller = null;
public Broadcaster broadcaster = null;
private Job firebaseInstanceIdJob;
private WindowDebouncer conversationListDebouncer;
private HandlerThread conversationListHandlerThread;
private Handler conversationListHandler;
@@ -151,6 +151,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
@Inject PushRegistry pushRegistry;
@Inject ConfigFactory configFactory;
@Inject LastSentTimestampCache lastSentTimestampCache;
@Inject VersionDataFetcher versionDataFetcher;
CallMessageProcessor callMessageProcessor;
MessagingModuleConfiguration messagingModuleConfiguration;
@@ -215,16 +216,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
MessagingModuleConfiguration.configure(this);
super.onCreate();
// we need to clear the snode and onionrequest databases once on first launch
// in order to apply a patch that adds a version number to the Snode objects.
if(!TextSecurePreferences.hasAppliedPatchSnodeVersion(this)) {
ThreadUtils.queue(() -> {
lokiAPIDatabase.clearSnodePool();
lokiAPIDatabase.clearOnionRequestPaths();
TextSecurePreferences.setHasAppliedPatchSnodeVersion(this, true);
});
}
messagingModuleConfiguration = new MessagingModuleConfiguration(
this,
storage,
@@ -272,7 +263,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
// If the user account hasn't been created or onboarding wasn't finished then don't start
// the pollers
if (TextSecurePreferences.getLocalNumber(this) == null || !TextSecurePreferences.hasSeenWelcomeScreen(this)) {
if (textSecurePreferences.getLocalNumber() == null) {
return;
}
@@ -285,6 +276,9 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
OpenGroupManager.INSTANCE.startPolling();
});
// fetch last version data
versionDataFetcher.startTimedVersionCheck();
}
@Override
@@ -297,12 +291,14 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
poller.stopIfNeeded();
}
ClosedGroupPollerV2.getShared().stopAll();
versionDataFetcher.stopTimedVersionCheck();
}
@Override
public void onTerminate() {
stopKovenant(); // Loki
OpenGroupManager.INSTANCE.stopPolling();
versionDataFetcher.stopTimedVersionCheck();
super.onTerminate();
}
@@ -462,6 +458,13 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
ClosedGroupPollerV2.getShared().start();
}
public void retrieveUserProfile() {
setUpPollingIfNeeded();
if (poller != null) {
poller.retrieveUserProfile();
}
}
private void resubmitProfilePictureIfNeeded() {
// Files expire on the file server after a while, so we simply re-upload the user's profile picture
// at a certain interval to ensure it's always available.
@@ -512,23 +515,23 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
});
}
public void clearAllData(boolean isMigratingToV2KeyPair) {
if (firebaseInstanceIdJob != null && firebaseInstanceIdJob.isActive()) {
firebaseInstanceIdJob.cancel(null);
}
String displayName = TextSecurePreferences.getProfileName(this);
boolean isUsingFCM = TextSecurePreferences.isPushEnabled(this);
// Method to clear the local data - returns true on success otherwise false
/**
* Clear all local profile data and message history then restart the app after a brief delay.
* @return true on success, false otherwise.
*/
@SuppressLint("ApplySharedPref")
public boolean clearAllData() {
TextSecurePreferences.clearAll(this);
if (isMigratingToV2KeyPair) {
TextSecurePreferences.setPushEnabled(this, isUsingFCM);
TextSecurePreferences.setProfileName(this, displayName);
}
getSharedPreferences(PREFERENCES_NAME, 0).edit().clear().commit();
if (!deleteDatabase(SQLCipherOpenHelper.DATABASE_NAME)) {
Log.d("Loki", "Failed to delete database.");
return false;
}
configFactory.keyPairChanged();
Util.runOnMain(() -> new Handler().postDelayed(ApplicationContext.this::restartApplication, 200));
return true;
}
public void restartApplication() {

View File

@@ -18,7 +18,7 @@ fun showMuteDialog(
private enum class Option(@StringRes val stringRes: Int, val getTime: () -> Long) {
ONE_HOUR(R.string.arrays__mute_for_one_hour, duration = TimeUnit.HOURS.toMillis(1)),
TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.DAYS.toMillis(2)),
TWO_HOURS(R.string.arrays__mute_for_two_hours, duration = TimeUnit.HOURS.toMillis(2)),
ONE_DAY(R.string.arrays__mute_for_one_day, duration = TimeUnit.DAYS.toMillis(1)),
SEVEN_DAYS(R.string.arrays__mute_for_seven_days, duration = TimeUnit.DAYS.toMillis(7)),
FOREVER(R.string.arrays__mute_forever, getTime = { Long.MAX_VALUE });

View File

@@ -15,7 +15,7 @@ import androidx.fragment.app.Fragment;
import org.session.libsession.utilities.TextSecurePreferences;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.home.HomeActivity;
import org.thoughtcrime.securesms.onboarding.LandingActivity;
import org.thoughtcrime.securesms.onboarding.landing.LandingActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import java.util.Locale;
@@ -125,12 +125,12 @@ public abstract class PassphraseRequiredActionBarActivity extends BaseActionBarA
}
private int getApplicationState(boolean locked) {
if (locked) {
if (TextSecurePreferences.getLocalNumber(this) == null) {
return STATE_WELCOME_SCREEN;
} else if (locked) {
return STATE_PROMPT_PASSPHRASE;
} else if (DatabaseUpgradeActivity.isUpdate(this)) {
return STATE_UPGRADE_DATABASE;
} else if (!TextSecurePreferences.hasSeenWelcomeScreen(this)) {
return STATE_WELCOME_SCREEN;
} else {
return STATE_NORMAL;
}

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
@@ -15,7 +17,7 @@ import androidx.annotation.LayoutRes
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.appcompat.app.AlertDialog
import androidx.core.view.setMargins
import androidx.core.text.HtmlCompat
import androidx.core.view.updateMargins
import androidx.fragment.app.Fragment
import network.loki.messenger.R
@@ -80,6 +82,10 @@ class SessionDialogBuilder(val context: Context) {
}.let(topView::addView)
}
fun htmlText(@StringRes id: Int, @StyleRes style: Int = 0, modify: TextView.() -> Unit = {}) {
text(HtmlCompat.fromHtml(context.resources.getString(id), 0))
}
fun view(view: View) = contentView.addView(view)
fun view(@LayoutRes layout: Int): View = LayoutInflater.from(context).inflate(layout, contentView)
@@ -108,14 +114,14 @@ class SessionDialogBuilder(val context: Context) {
options,
) { dialog, it -> onSelect(it); dialog.dismiss() }
fun destructiveButton(
fun dangerButton(
@StringRes text: Int,
@StringRes contentDescription: Int = text,
listener: () -> Unit = {}
) = button(
text,
contentDescription,
R.style.Widget_Session_Button_Dialog_DestructiveText,
R.style.Widget_Session_Button_Dialog_DangerText,
) { listener() }
fun okButton(listener: (() -> Unit) = {}) = button(android.R.string.ok) { listener() }
@@ -143,6 +149,20 @@ class SessionDialogBuilder(val context: Context) {
fun Context.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(this).apply { build() }.show()
fun Context.showOpenUrlDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(this).apply {
title(R.string.urlOpen)
text(R.string.urlOpenBrowser)
build()
}.show()
fun Context.showOpenUrlDialog(url: String): AlertDialog =
showOpenUrlDialog {
okButton { openUrl(url) }
cancelButton()
}
fun Context.openUrl(url: String) = Intent(Intent.ACTION_VIEW, Uri.parse(url)).let(::startActivity)
fun Fragment.showSessionDialog(build: SessionDialogBuilder.() -> Unit): AlertDialog =
SessionDialogBuilder(requireContext()).apply { build() }.show()

View File

@@ -1,28 +1,22 @@
package org.thoughtcrime.securesms.audio;
import android.annotation.TargetApi;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.ParcelFileDescriptor;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.MediaTypes;
import org.session.libsignal.utilities.Log;
import android.util.Pair;
import androidx.annotation.NonNull;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import org.session.libsession.utilities.MediaTypes;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.SettableFuture;
import org.session.libsignal.utilities.ThreadUtils;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.session.libsignal.utilities.ThreadUtils;
import org.session.libsession.utilities.Util;
import org.session.libsignal.utilities.ListenableFuture;
import org.session.libsignal.utilities.SettableFuture;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
public class AudioRecorder {
private static final String TAG = AudioRecorder.class.getSimpleName();
@@ -34,11 +28,16 @@ public class AudioRecorder {
private AudioCodec audioCodec;
private Uri captureUri;
// Simple interface that allows us to provide a callback method to our `startRecording` method
public interface AudioMessageRecordingFinishedCallback {
void onAudioMessageRecordingFinished();
}
public AudioRecorder(@NonNull Context context) {
this.context = context;
}
public void startRecording() {
public void startRecording(AudioMessageRecordingFinishedCallback callback) {
Log.i(TAG, "startRecording()");
executor.execute(() -> {
@@ -55,9 +54,11 @@ public class AudioRecorder {
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaTypes.AUDIO_AAC)
.createForSingleSessionOnDisk(context, e -> Log.w(TAG, "Error during recording", e));
audioCodec = new AudioCodec();
audioCodec = new AudioCodec();
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
callback.onAudioMessageRecordingFinished();
} catch (IOException e) {
Log.w(TAG, e);
}

View File

@@ -0,0 +1,87 @@
package org.thoughtcrime.securesms.calls
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.provider.Settings
import androidx.core.content.ContextCompat.getSystemService
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity.SENSOR_SERVICE
import org.thoughtcrime.securesms.webrtc.Orientation
import kotlin.math.asin
class OrientationManager(private val context: Context): SensorEventListener {
private var sensorManager: SensorManager? = null
private var rotationVectorSensor: Sensor? = null
private val _orientation = MutableStateFlow(Orientation.UNKNOWN)
val orientation: StateFlow<Orientation> = _orientation
fun startOrientationListener(){
// create the sensor manager if it's still null
if(sensorManager == null) {
sensorManager = context.getSystemService(SENSOR_SERVICE) as SensorManager
}
if(rotationVectorSensor == null) {
rotationVectorSensor = sensorManager!!.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
}
sensorManager?.registerListener(this, rotationVectorSensor, SensorManager.SENSOR_DELAY_UI)
}
fun stopOrientationListener(){
sensorManager?.unregisterListener(this)
}
fun destroy(){
stopOrientationListener()
sensorManager = null
rotationVectorSensor = null
_orientation.value = Orientation.UNKNOWN
}
override fun onSensorChanged(event: SensorEvent) {
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) {
// if auto-rotate is off, bail and send UNKNOWN
if (!isAutoRotateOn()) {
_orientation.value = Orientation.UNKNOWN
return
}
// Get the quaternion from the rotation vector sensor
val quaternion = FloatArray(4)
SensorManager.getQuaternionFromVector(quaternion, event.values)
// Calculate Euler angles from the quaternion
val pitch = asin(2.0 * (quaternion[0] * quaternion[2] - quaternion[3] * quaternion[1]))
// Convert radians to degrees
val pitchDegrees = Math.toDegrees(pitch).toFloat()
// Determine the device's orientation based on the pitch and roll values
val currentOrientation = when {
pitchDegrees > 45 -> Orientation.LANDSCAPE
pitchDegrees < -45 -> Orientation.REVERSED_LANDSCAPE
else -> Orientation.PORTRAIT
}
if (currentOrientation != _orientation.value) {
_orientation.value = currentOrientation
}
}
}
//Function to check if Android System Auto-rotate is on or off
private fun isAutoRotateOn(): Boolean {
return Settings.System.getInt(
context.contentResolver,
Settings.System.ACCELEROMETER_ROTATION, 0
) == 1
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}

View File

@@ -5,11 +5,17 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Outline
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorManager
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.MenuItem
import android.view.OrientationEventListener
import android.view.View
import android.view.ViewOutlineProvider
import android.view.WindowManager
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
@@ -21,13 +27,14 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import android.provider.Settings
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityWebrtcBinding
import org.apache.commons.lang3.time.DurationFormatUtils
import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
@@ -43,8 +50,10 @@ import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_OUTGOING
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_PRE_INIT
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RECONNECTING
import org.thoughtcrime.securesms.webrtc.CallViewModel.State.CALL_RINGING
import org.thoughtcrime.securesms.webrtc.Orientation
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.EARPIECE
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.AudioDevice.SPEAKER_PHONE
import kotlin.math.asin
@AndroidEntryPoint
class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
@@ -71,16 +80,13 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
}
private var hangupReceiver: BroadcastReceiver? = null
private val rotationListener by lazy {
object : OrientationEventListener(this) {
override fun onOrientationChanged(orientation: Int) {
if ((orientation + 15) % 90 < 30) {
viewModel.deviceRotation = orientation
// updateControlsRotation(orientation.quadrantRotation() * -1)
}
}
}
}
/**
* We need to track the device's orientation so we can calculate whether or not to rotate the video streams
* This works a lot better than using `OrientationEventListener > onOrientationChanged'
* which gives us a rotation angle that doesn't take into account pitch vs roll, so tipping the device from front to back would
* trigger the video rotation logic, while we really only want it when the device is in portrait or landscape.
*/
private var orientationManager = OrientationManager(this)
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
@@ -102,13 +108,6 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
// Only enable auto-rotate if system auto-rotate is enabled
if (isAutoRotateOn()) {
rotationListener.enable()
} else {
rotationListener.disable()
}
binding = ActivityWebrtcBinding.inflate(layoutInflater)
setContentView(binding.root)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
@@ -136,6 +135,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(false)
}
binding.floatingRendererContainer.setOnClickListener {
viewModel.swapVideos()
}
binding.microphoneButton.setOnClickListener {
val audioEnabledIntent =
WebRtcCallService.microphoneIntent(this, !viewModel.microphoneEnabled)
@@ -174,7 +177,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.onAllGranted {
val intent = WebRtcCallService.cameraEnabled(this, !viewModel.videoEnabled)
val intent = WebRtcCallService.cameraEnabled(this, !viewModel.videoState.value.userVideoEnabled)
startService(intent)
}
.execute()
@@ -191,14 +194,54 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
onBackPressed()
}
lifecycleScope.launch {
orientationManager.orientation.collect { orientation ->
viewModel.deviceOrientation = orientation
updateControlsRotation()
}
}
clipFloatingInsets()
// set up the user avatar
TextSecurePreferences.getLocalNumber(this)?.let{
val username = TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(it)
binding.userAvatar.apply {
publicKey = it
displayName = username
update()
}
}
}
//Function to check if Android System Auto-rotate is on or off
private fun isAutoRotateOn(): Boolean {
return Settings.System.getInt(
contentResolver,
Settings.System.ACCELEROMETER_ROTATION, 0
) == 1
/**
* Makes sure the floating video inset has clipped rounded corners, included with the video stream itself
*/
private fun clipFloatingInsets() {
// clip the video inset with rounded corners
val videoInsetProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
// all corners
outline.setRoundRect(
0, 0, view.width, view.height,
resources.getDimensionPixelSize(R.dimen.video_inset_radius).toFloat()
)
}
}
binding.floatingRendererContainer.outlineProvider = videoInsetProvider
binding.floatingRendererContainer.clipToOutline = true
}
override fun onResume() {
super.onResume()
orientationManager.startOrientationListener()
}
override fun onPause() {
super.onPause()
orientationManager.stopOrientationListener()
}
override fun onDestroy() {
@@ -206,7 +249,8 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
hangupReceiver?.let { receiver ->
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
}
rotationListener.disable()
orientationManager.destroy()
}
private fun answerCall() {
@@ -214,15 +258,33 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
ContextCompat.startForegroundService(this, answerIntent)
}
private fun updateControlsRotation(newRotation: Int) {
private fun updateControlsRotation() {
with (binding) {
val rotation = newRotation.toFloat()
remoteRecipient.rotation = rotation
speakerPhoneButton.rotation = rotation
microphoneButton.rotation = rotation
enableCameraButton.rotation = rotation
switchCameraButton.rotation = rotation
endCallButton.rotation = rotation
val rotation = when(viewModel.deviceOrientation){
Orientation.LANDSCAPE -> -90f
Orientation.REVERSED_LANDSCAPE -> 90f
else -> 0f
}
userAvatar.animate().cancel()
userAvatar.animate().rotation(rotation).start()
contactAvatar.animate().cancel()
contactAvatar.animate().rotation(rotation).start()
speakerPhoneButton.animate().cancel()
speakerPhoneButton.animate().rotation(rotation).start()
microphoneButton.animate().cancel()
microphoneButton.animate().rotation(rotation).start()
enableCameraButton.animate().cancel()
enableCameraButton.animate().rotation(rotation).start()
switchCameraButton.animate().cancel()
switchCameraButton.animate().rotation(rotation).start()
endCallButton.animate().cancel()
endCallButton.animate().rotation(rotation).start()
}
}
@@ -280,44 +342,20 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
launch {
viewModel.recipient.collect { latestRecipient ->
binding.contactAvatar.recycle()
if (latestRecipient.recipient != null) {
val publicKey = latestRecipient.recipient.address.serialize()
val displayName = getUserDisplayName(publicKey)
supportActionBar?.title = displayName
val signalProfilePicture = latestRecipient.recipient.contactPhoto
val avatar = (signalProfilePicture as? ProfileContactPhoto)?.avatarObject
val sizeInPX =
resources.getDimensionPixelSize(R.dimen.extra_large_profile_picture_size)
binding.remoteRecipientName.text = displayName
if (signalProfilePicture != null && avatar != "0" && avatar != "") {
glide.clear(binding.remoteRecipient)
glide.load(signalProfilePicture)
.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC)
.circleCrop()
.error(
AvatarPlaceholderGenerator.generate(
this@WebRtcCallActivity,
sizeInPX,
publicKey,
displayName
)
)
.into(binding.remoteRecipient)
} else {
glide.clear(binding.remoteRecipient)
glide.load(
AvatarPlaceholderGenerator.generate(
this@WebRtcCallActivity,
sizeInPX,
publicKey,
displayName
)
)
.diskCacheStrategy(DiskCacheStrategy.ALL).circleCrop()
.into(binding.remoteRecipient)
val contactPublicKey = latestRecipient.recipient.address.serialize()
val contactDisplayName = getUserDisplayName(contactPublicKey)
supportActionBar?.title = contactDisplayName
binding.remoteRecipientName.text = contactDisplayName
// sort out the contact's avatar
binding.contactAvatar.apply {
publicKey = contactPublicKey
displayName = contactDisplayName
update()
}
} else {
glide.clear(binding.remoteRecipient)
}
}
}
@@ -346,49 +384,75 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
}
}
// handle video state
launch {
viewModel.localVideoEnabledState.collect { isEnabled ->
binding.localRenderer.removeAllViews()
if (isEnabled) {
viewModel.localRenderer?.let { surfaceView ->
surfaceView.setZOrderOnTop(true)
viewModel.videoState.collect { state ->
binding.floatingRenderer.removeAllViews()
binding.fullscreenRenderer.removeAllViews()
// Mirror the video preview of the person making the call to prevent disorienting them
surfaceView.setMirror(true)
binding.localRenderer.addView(surfaceView)
// handle fullscreen video window
if(state.showFullscreenVideo()){
viewModel.fullscreenRenderer?.let { surfaceView ->
binding.fullscreenRenderer.addView(surfaceView)
binding.fullscreenRenderer.isVisible = true
hideAvatar()
}
} else {
binding.fullscreenRenderer.isVisible = false
showAvatar(state.swapped)
}
binding.localRenderer.isVisible = isEnabled
binding.enableCameraButton.isSelected = isEnabled
}
}
launch {
viewModel.remoteVideoEnabledState.collect { isEnabled ->
binding.remoteRenderer.removeAllViews()
if (isEnabled) {
viewModel.remoteRenderer?.let { surfaceView ->
binding.remoteRenderer.addView(surfaceView)
// handle floating video window
if(state.showFloatingVideo()){
viewModel.floatingRenderer?.let { surfaceView ->
binding.floatingRenderer.addView(surfaceView)
binding.floatingRenderer.isVisible = true
binding.swapViewIcon.bringToFront()
}
} else {
binding.floatingRenderer.isVisible = false
}
binding.remoteRenderer.isVisible = isEnabled
binding.remoteRecipient.isVisible = !isEnabled
// the floating video inset (empty or not) should be shown
// the moment we have either of the video streams
val showFloatingContainer = state.userVideoEnabled || state.remoteVideoEnabled
binding.floatingRendererContainer.isVisible = showFloatingContainer
binding.swapViewIcon.isVisible = showFloatingContainer
// make sure to default to the contact's avatar if the floating container is not visible
if (!showFloatingContainer) showAvatar(false)
// handle buttons
binding.enableCameraButton.isSelected = state.userVideoEnabled
}
}
}
}
/**
* Shows the avatar image.
* If @showUserAvatar is true, the user's avatar is shown, otherwise the contact's avatar is shown.
*/
private fun showAvatar(showUserAvatar: Boolean) {
binding.userAvatar.isVisible = showUserAvatar
binding.contactAvatar.isVisible = !showUserAvatar
}
private fun hideAvatar() {
binding.userAvatar.isVisible = false
binding.contactAvatar.isVisible = false
}
private fun getUserDisplayName(publicKey: String): String {
val contact =
DatabaseComponent.get(this).sessionContactDatabase().getContactWithSessionID(publicKey)
DatabaseComponent.get(this).sessionContactDatabase().getContactWithAccountID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
override fun onStop() {
super.onStop()
uiJob?.cancel()
binding.remoteRenderer.removeAllViews()
binding.localRenderer.removeAllViews()
binding.fullscreenRenderer.removeAllViews()
binding.floatingRenderer.removeAllViews()
}
}

View File

@@ -15,8 +15,10 @@ import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.avatars.ResourceContactPhoto
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.AppTextSecurePreferences
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
@@ -24,13 +26,16 @@ import org.thoughtcrime.securesms.mms.GlideRequests
class ProfilePictureView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : RelativeLayout(context, attrs) {
private val TAG = "ProfilePictureView"
private val binding = ViewProfilePictureBinding.inflate(LayoutInflater.from(context), this)
private val glide: GlideRequests = GlideApp.with(this)
private val prefs = AppTextSecurePreferences(context)
private val userPublicKey = prefs.getLocalNumber()
var publicKey: String? = null
var displayName: String? = null
var additionalPublicKey: String? = null
var additionalDisplayName: String? = null
var isLarge = false
private val profilePicturesCache = mutableMapOf<View, Recipient>()
private val unknownRecipientDrawable by lazy { ResourceContactPhoto(R.drawable.ic_profile_default)
@@ -38,25 +43,28 @@ class ProfilePictureView @JvmOverloads constructor(
private val unknownOpenGroupDrawable by lazy { ResourceContactPhoto(R.drawable.ic_notification)
.asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context), false) }
// endregion
constructor(context: Context, sender: Recipient): this(context) {
update(sender)
}
// region Updating
fun update(recipient: Recipient) {
fun getUserDisplayName(publicKey: String): String {
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
recipient.run { update(address, isClosedGroupRecipient, isOpenGroupInboxRecipient) }
}
if (recipient.isClosedGroupRecipient) {
fun update(
address: Address,
isClosedGroupRecipient: Boolean = false,
isOpenGroupInboxRecipient: Boolean = false
) {
fun getUserDisplayName(publicKey: String): String = prefs.takeIf { userPublicKey == publicKey }?.getProfileName()
?: DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)?.displayName(Contact.ContactContext.REGULAR)
?: publicKey
if (isClosedGroupRecipient) {
val members = DatabaseComponent.get(context).groupDatabase()
.getGroupMemberAddresses(recipient.address.toGroupString(), true)
.sorted()
.take(2)
.toMutableList()
.getGroupMemberAddresses(address.toGroupString(), true)
.sorted()
.take(2)
if (members.size <= 1) {
publicKey = ""
displayName = ""
@@ -70,13 +78,13 @@ class ProfilePictureView @JvmOverloads constructor(
additionalPublicKey = apk
additionalDisplayName = getUserDisplayName(apk)
}
} else if(recipient.isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInboxSessionId(recipient.address.serialize())
} else if(isOpenGroupInboxRecipient) {
val publicKey = GroupUtil.getDecodedOpenGroupInboxAccountId(address.serialize())
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
additionalPublicKey = null
} else {
val publicKey = recipient.address.toString()
val publicKey = address.serialize()
this.publicKey = publicKey
displayName = getUserDisplayName(publicKey)
additionalPublicKey = null
@@ -85,31 +93,27 @@ class ProfilePictureView @JvmOverloads constructor(
}
fun update() {
val publicKey = publicKey ?: return
val publicKey = publicKey ?: return Log.w(TAG, "Could not find public key to update profile picture")
val additionalPublicKey = additionalPublicKey
// if we have a multi avatar setup
if (additionalPublicKey != null) {
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
binding.doubleModeImageViewContainer.visibility = View.VISIBLE
} else {
// clear single image
glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE
} else { // single image mode
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE
// clear multi image
glide.clear(binding.doubleModeImageView1)
glide.clear(binding.doubleModeImageView2)
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && !isLarge) {
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && isLarge) {
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName)
binding.largeSingleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.largeSingleModeImageView)
binding.largeSingleModeImageView.visibility = View.INVISIBLE
}
}
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {

View File

@@ -1,64 +0,0 @@
package org.thoughtcrime.securesms.components.emoji;
import android.net.Uri;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.v2.Util;
import java.util.LinkedList;
import java.util.List;
public class CompositeEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final List<EmojiPageModel> models;
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull List<EmojiPageModel> models) {
this.iconAttr = iconAttr;
this.models = models;
}
@Override
public String getKey() {
return Util.hasItems(models) ? models.get(0).getKey() : "";
}
public int getIconAttr() {
return iconAttr;
}
@Override
public @NonNull List<String> getEmoji() {
List<String> emojis = new LinkedList<>();
for (EmojiPageModel model : models) {
emojis.addAll(model.getEmoji());
}
return emojis;
}
@Override
public @NonNull List<Emoji> getDisplayEmoji() {
List<Emoji> emojis = new LinkedList<>();
for (EmojiPageModel model : models) {
emojis.addAll(model.getDisplayEmoji());
}
return emojis;
}
@Override
public boolean hasSpriteMap() {
return false;
}
@Override
public @Nullable Uri getSpriteUri() {
return null;
}
@Override
public boolean isDynamic() {
return false;
}
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.components.emoji
import android.net.Uri
import androidx.annotation.AttrRes
import java.util.LinkedList
class CompositeEmojiPageModel(
@field:AttrRes @param:AttrRes private val iconAttr: Int,
private val models: List<EmojiPageModel>
) : EmojiPageModel {
override fun getKey(): String {
return if (models.isEmpty()) "" else models[0].key
}
override fun getIconAttr(): Int { return iconAttr }
override fun getEmoji(): List<String> {
val emojis: MutableList<String> = LinkedList()
for (model in models) {
emojis.addAll(model.emoji)
}
return emojis
}
override fun getDisplayEmoji(): List<Emoji> {
val emojis: MutableList<Emoji> = LinkedList()
for (model in models) {
emojis.addAll(model.displayEmoji)
}
return emojis
}
override fun hasSpriteMap(): Boolean { return false }
override fun getSpriteUri(): Uri? { return null }
override fun isDynamic(): Boolean { return false }
}

View File

@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.components.menu
import android.content.Context
import androidx.annotation.AttrRes
import androidx.annotation.ColorRes
import androidx.annotation.ColorInt
/**
* Represents an action to be rendered
@@ -13,5 +13,5 @@ data class ActionItem(
val action: Runnable,
val contentDescription: Int? = null,
val subtitle: ((Context) -> CharSequence?)? = null,
@ColorRes val color: Int? = null,
@ColorInt val color: Int? = null,
)

View File

@@ -78,7 +78,7 @@ class ContextMenuList(recyclerView: RecyclerView, onItemClick: () -> Unit) {
override fun bind(model: DisplayItem) {
val item = model.item
val color = item.color?.let { ContextCompat.getColor(context, it) }
val color = item.color
if (item.iconRes > 0) {
val typedValue = TypedValue()

View File

@@ -49,7 +49,7 @@ class UserView : LinearLayout {
val isLocalUser = user.isLocalNumber
fun getUserDisplayName(publicKey: String): String {
if (isLocalUser) return context.getString(R.string.MessageRecord_you)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
return contact?.displayName(Contact.ContactContext.REGULAR) ?: publicKey
}
val address = user.address.serialize()

View File

@@ -14,7 +14,6 @@ import org.session.libsession.utilities.ExpirationUtil
import org.session.libsession.utilities.SSKEnvironment.MessageExpirationManagerProtocol
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.getExpirationTypeDisplayValue
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
@@ -57,7 +56,7 @@ class DisappearingMessages @Inject constructor(
context.getExpirationTypeDisplayValue(message.isNotDisappearAfterRead)
)
})
destructiveButton(
dangerButton(
text = if (message.expiresIn == 0L) R.string.dialog_disappearing_messages_follow_setting_confirm else R.string.dialog_disappearing_messages_follow_setting_set,
contentDescription = if (message.expiresIn == 0L) R.string.AccessibilityId_confirm else R.string.AccessibilityId_set_button
) {

View File

@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.Disappear
import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.UiState
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.setThemedContent
import javax.inject.Inject
@AndroidEntryPoint
@@ -45,7 +45,7 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() {
setUpToolbar()
binding.container.setContent { DisappearingMessagesScreen() }
binding.container.setThemedContent { DisappearingMessagesScreen() }
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -87,8 +87,6 @@ class DisappearingMessagesActivity: PassphraseRequiredActionBarActivity() {
@Composable
fun DisappearingMessagesScreen() {
val uiState by viewModel.uiState.collectAsState(UiState())
AppTheme {
DisappearingMessages(uiState, callbacks = viewModel)
}
DisappearingMessages(uiState, callbacks = viewModel)
}
}

View File

@@ -13,8 +13,8 @@ import kotlin.time.Duration.Companion.seconds
fun State.toUiState() = UiState(
cards = listOfNotNull(
typeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_delete_type), it) },
timeOptions()?.let { ExpiryOptionsCard(GetString(R.string.activity_disappearing_messages_timer), it) }
typeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.activity_disappearing_messages_delete_type), it) },
timeOptions()?.let { ExpiryOptionsCardData(GetString(R.string.activity_disappearing_messages_timer), it) }
),
showGroupFooter = isGroup && isNewConfigEnabled,
showSetButton = isSelfAdmin

View File

@@ -3,31 +3,32 @@ package org.thoughtcrime.securesms.conversation.disappearingmessages.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Text
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.ui.Callbacks
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.NoOpCallbacks
import org.thoughtcrime.securesms.ui.OptionsCard
import org.thoughtcrime.securesms.ui.OutlineButton
import org.thoughtcrime.securesms.ui.RadioOption
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.fadingEdges
import org.thoughtcrime.securesms.ui.theme.LocalType
typealias ExpiryCallbacks = Callbacks<ExpiryMode>
typealias ExpiryRadioOption = RadioOption<ExpiryMode>
@@ -40,35 +41,42 @@ fun DisappearingMessages(
) {
val scrollState = rememberScrollState()
Column(modifier = modifier.padding(horizontal = 32.dp)) {
Column(modifier = modifier.padding(horizontal = LocalDimensions.current.spacing)) {
Box(modifier = Modifier.weight(1f)) {
Column(
modifier = Modifier
.padding(bottom = 20.dp)
.padding(vertical = LocalDimensions.current.spacing)
.verticalScroll(scrollState)
.fadingEdges(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
state.cards.forEach {
OptionsCard(it, callbacks)
state.cards.forEachIndexed { index, option ->
OptionsCard(option, callbacks)
// add spacing if not the last item
if(index != state.cards.lastIndex){
Spacer(modifier = Modifier.height(LocalDimensions.current.spacing))
}
}
if (state.showGroupFooter) Text(text = stringResource(R.string.activity_disappearing_messages_group_footer),
style = TextStyle(
fontSize = 11.sp,
fontWeight = FontWeight(400),
color = Color(0xFFA1A2A1),
textAlign = TextAlign.Center),
modifier = Modifier.fillMaxWidth())
if (state.showGroupFooter) Text(
text = stringResource(R.string.activity_disappearing_messages_group_footer),
style = LocalType.current.extraSmall,
fontWeight = FontWeight(400),
color = LocalColors.current.textSecondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = LocalDimensions.current.xsSpacing)
)
}
}
if (state.showSetButton) OutlineButton(
GetString(R.string.disappearing_messages_set_button_title),
if (state.showSetButton) SlimOutlineButton(
stringResource(R.string.disappearing_messages_set_button_title),
modifier = Modifier
.contentDescription(GetString(R.string.AccessibilityId_set_button))
.contentDescription(R.string.AccessibilityId_set_button)
.align(Alignment.CenterHorizontally)
.padding(bottom = 20.dp),
.padding(bottom = LocalDimensions.current.spacing),
onClick = callbacks::onSetClick
)
}

View File

@@ -7,19 +7,19 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType
import org.thoughtcrime.securesms.conversation.disappearingmessages.State
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.ThemeColors
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
@Preview(widthDp = 450, heightDp = 700)
@Composable
fun PreviewStates(
@PreviewParameter(StatePreviewParameterProvider::class) state: State
) {
PreviewTheme(R.style.Classic_Dark) {
PreviewTheme {
DisappearingMessages(
state.toUiState()
)
@@ -51,9 +51,9 @@ class StatePreviewParameterProvider : PreviewParameterProvider<State> {
@Preview
@Composable
fun PreviewThemes(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(themeResId) {
PreviewTheme(colors) {
DisappearingMessages(
State(expiryMode = ExpiryMode.AfterSend(43200)).toUiState(),
modifier = Modifier.size(400.dp, 600.dp)

View File

@@ -5,15 +5,15 @@ import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.RadioOption
typealias ExpiryOptionsCard = OptionsCard<ExpiryMode>
typealias ExpiryOptionsCardData = OptionsCardData<ExpiryMode>
data class UiState(
val cards: List<ExpiryOptionsCard> = emptyList(),
val cards: List<ExpiryOptionsCardData> = emptyList(),
val showGroupFooter: Boolean = false,
val showSetButton: Boolean = true
) {
constructor(
vararg cards: ExpiryOptionsCard,
vararg cards: ExpiryOptionsCardData,
showGroupFooter: Boolean = false,
showSetButton: Boolean = true,
): this(
@@ -23,7 +23,7 @@ data class UiState(
)
}
data class OptionsCard<T>(
data class OptionsCardData<T>(
val title: GetString,
val options: List<RadioOption<T>>
) {

View File

@@ -1,129 +0,0 @@
package org.thoughtcrime.securesms.conversation.paging
import androidx.annotation.WorkerThread
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingSource
import androidx.paging.PagingState
import androidx.recyclerview.widget.DiffUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.session.libsession.messaging.contacts.Contact
import org.thoughtcrime.securesms.database.MmsSmsDatabase
import org.thoughtcrime.securesms.database.SessionContactDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
private const val TIME_BUCKET = 600000L // bucket into 10 minute increments
private fun config() = PagingConfig(
pageSize = 25,
maxSize = 100,
enablePlaceholders = false
)
fun Long.bucketed(): Long = (TIME_BUCKET - this % TIME_BUCKET) + this
fun conversationPager(threadId: Long, initialKey: PageLoad? = null, db: MmsSmsDatabase, contactDb: SessionContactDatabase) = Pager(config(), initialKey = initialKey) {
ConversationPagingSource(threadId, db, contactDb)
}
class ConversationPagerDiffCallback: DiffUtil.ItemCallback<MessageAndContact>() {
override fun areItemsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean =
oldItem.message.id == newItem.message.id && oldItem.message.isMms == newItem.message.isMms
override fun areContentsTheSame(oldItem: MessageAndContact, newItem: MessageAndContact): Boolean =
oldItem == newItem
}
data class MessageAndContact(val message: MessageRecord,
val contact: Contact?)
data class PageLoad(val fromTime: Long, val toTime: Long? = null)
class ConversationPagingSource(
private val threadId: Long,
private val messageDb: MmsSmsDatabase,
private val contactDb: SessionContactDatabase
): PagingSource<PageLoad, MessageAndContact>() {
override fun getRefreshKey(state: PagingState<PageLoad, MessageAndContact>): PageLoad? {
val anchorPosition = state.anchorPosition ?: return null
val anchorPage = state.closestPageToPosition(anchorPosition) ?: return null
val next = anchorPage.nextKey?.fromTime
val previous = anchorPage.prevKey?.fromTime ?: anchorPage.data.firstOrNull()?.message?.dateSent ?: return null
return PageLoad(previous, next)
}
private val contactCache = mutableMapOf<String, Contact>()
@WorkerThread
private fun getContact(sessionId: String): Contact? {
contactCache[sessionId]?.let { contact ->
return contact
} ?: run {
contactDb.getContactWithSessionID(sessionId)?.let { contact ->
contactCache[sessionId] = contact
return contact
}
}
return null
}
override suspend fun load(params: LoadParams<PageLoad>): LoadResult<PageLoad, MessageAndContact> {
val pageLoad = params.key ?: withContext(Dispatchers.IO) {
messageDb.getConversationSnippet(threadId).use {
val reader = messageDb.readerFor(it)
var record: MessageRecord? = null
if (reader != null) {
record = reader.next
while (record != null && record.isDeleted) {
record = reader.next
}
}
record?.dateSent?.let { fromTime ->
PageLoad(fromTime)
}
}
} ?: return LoadResult.Page(emptyList(), null, null)
val result = withContext(Dispatchers.IO) {
val cursor = messageDb.getConversationPage(
threadId,
pageLoad.fromTime,
pageLoad.toTime ?: -1L,
params.loadSize
)
val processedList = mutableListOf<MessageAndContact>()
val reader = messageDb.readerFor(cursor)
while (reader.next != null && !invalid) {
reader.current?.let { item ->
val contact = getContact(item.individualRecipient.address.serialize())
processedList += MessageAndContact(item, contact)
}
}
reader.close()
processedList.toMutableList()
}
val hasNext = withContext(Dispatchers.IO) {
if (result.isEmpty()) return@withContext false
val lastTime = result.last().message.dateSent
messageDb.hasNextPage(threadId, lastTime)
}
val nextCheckTime = if (hasNext) {
val lastSent = result.last().message.dateSent
if (lastSent == pageLoad.fromTime) null else lastSent
} else null
val hasPrevious = withContext(Dispatchers.IO) { messageDb.hasPreviousPage(threadId, pageLoad.fromTime) }
val nextKey = if (!hasNext) null else nextCheckTime
val prevKey = if (!hasPrevious) null else messageDb.getPreviousPage(threadId, pageLoad.fromTime, params.loadSize)
return LoadResult.Page(
data = result, // next check time is not null if drop is true
prevKey = prevKey?.let { PageLoad(it, pageLoad.fromTime) },
nextKey = nextKey?.let { PageLoad(it) }
)
}
}

View File

@@ -1,103 +0,0 @@
package org.thoughtcrime.securesms.conversation.start
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.databinding.ContactSectionHeaderBinding
import network.loki.messenger.databinding.ViewContactBinding
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.mms.GlideRequests
sealed class ContactListItem {
class Header(val name: String) : ContactListItem()
class Contact(val recipient: Recipient, val displayName: String) : ContactListItem()
}
class ContactListAdapter(
private val context: Context,
private val glide: GlideRequests,
private val listener: (Recipient) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var items = listOf<ContactListItem>()
set(value) {
field = value
notifyDataSetChanged()
}
private object ViewType {
const val Contact = 0
const val Header = 1
}
class ContactViewHolder(private val binding: ViewContactBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(contact: ContactListItem.Contact, glide: GlideRequests, listener: (Recipient) -> Unit) {
binding.profilePictureView.update(contact.recipient)
binding.nameTextView.text = contact.displayName
binding.root.setOnClickListener { listener(contact.recipient) }
// TODO: When we implement deleting contacts (hide might be safest for now) then probably set a long-click listener here w/ something like:
/*
binding.root.setOnLongClickListener {
Log.w("[ACL]", "Long clicked on contact ${contact.recipient.name}")
binding.contentView.context.showSessionDialog {
title("Delete Contact")
text("Are you sure you want to delete this contact?")
button(R.string.delete) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(contact.recipient.address.serialize()) { priority = PRIORITY_HIDDEN }
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
endActionMode()
}
cancelButton(::endActionMode)
}
true
}
*/
}
fun unbind() { binding.profilePictureView.recycle() }
}
class HeaderViewHolder(
private val binding: ContactSectionHeaderBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ContactListItem.Header) {
with(binding) {
label.text = item.name
}
}
}
override fun getItemCount(): Int { return items.size }
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
super.onViewRecycled(holder)
if (holder is ContactViewHolder) { holder.unbind() }
}
override fun getItemViewType(position: Int): Int {
return when (items[position]) {
is ContactListItem.Header -> ViewType.Header
else -> ViewType.Contact
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == ViewType.Contact) {
ContactViewHolder(ViewContactBinding.inflate(LayoutInflater.from(context), parent, false))
} else {
HeaderViewHolder(ContactSectionHeaderBinding.inflate(LayoutInflater.from(context), parent, false))
}
}
override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
if (viewHolder is ContactViewHolder) {
viewHolder.bind(item as ContactListItem.Contact, glide, listener)
} else if (viewHolder is HeaderViewHolder) {
viewHolder.bind(item as ContactListItem.Header)
}
}
}

View File

@@ -1,10 +1,21 @@
package org.thoughtcrime.securesms.conversation.start
interface NewConversationDelegate {
interface StartConversationDelegate {
fun onNewMessageSelected()
fun onCreateGroupSelected()
fun onJoinCommunitySelected()
fun onContactSelected(address: String)
fun onDialogBackPressed()
fun onDialogClosePressed()
fun onInviteFriend()
}
object NullStartConversationDelegate: StartConversationDelegate {
override fun onNewMessageSelected() {}
override fun onCreateGroupSelected() {}
override fun onJoinCommunitySelected() {}
override fun onContactSelected(address: String) {}
override fun onDialogBackPressed() {}
override fun onDialogClosePressed() {}
override fun onInviteFriend() {}
}

View File

@@ -7,6 +7,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import androidx.fragment.app.Fragment
import androidx.fragment.app.commit
import com.google.android.material.bottomsheet.BottomSheetBehavior
@@ -15,15 +16,22 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.modifyLayoutParams
import org.thoughtcrime.securesms.conversation.start.home.StartConversationHomeFragment
import org.thoughtcrime.securesms.conversation.start.invitefriend.InviteFriendFragment
import org.thoughtcrime.securesms.conversation.start.newmessage.NewMessageFragment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dms.NewMessageFragment
import org.thoughtcrime.securesms.groups.CreateGroupFragment
import org.thoughtcrime.securesms.groups.JoinCommunityFragment
@AndroidEntryPoint
class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDelegate {
class StartConversationFragment : BottomSheetDialogFragment(), StartConversationDelegate {
private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * 0.94).toInt() }
companion object{
const val PEEK_RATIO = 0.94f
}
private val defaultPeekHeight: Int by lazy { (Resources.getSystem().displayMetrics.heightPixels * PEEK_RATIO).toInt() }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
@@ -35,38 +43,34 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
replaceFragment(
fragment = NewConversationHomeFragment().apply { delegate = this@NewConversationFragment },
fragmentKey = NewConversationHomeFragment::class.java.simpleName
fragment = StartConversationHomeFragment().also { it.delegate.value = this },
fragmentKey = StartConversationHomeFragment::class.java.simpleName
)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet)
dialog.setOnShowListener {
val bottomSheetDialog = it as BottomSheetDialog
val parentLayout =
bottomSheetDialog.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)
parentLayout?.let { it ->
val behaviour = BottomSheetBehavior.from(it)
val layoutParams = it.layoutParams
layoutParams.height = defaultPeekHeight
it.layoutParams = layoutParams
behaviour.state = BottomSheetBehavior.STATE_EXPANDED
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
BottomSheetDialog(requireContext(), R.style.Theme_Session_BottomSheet).apply {
setOnShowListener { _ ->
findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)?.apply {
modifyLayoutParams<LayoutParams> { height = defaultPeekHeight }
}?.let { BottomSheetBehavior.from(it) }?.apply {
skipCollapsed = true
state = BottomSheetBehavior.STATE_EXPANDED
}
}
}
return dialog
}
override fun onNewMessageSelected() {
replaceFragment(NewMessageFragment().apply { delegate = this@NewConversationFragment })
replaceFragment(NewMessageFragment().also { it.delegate = this })
}
override fun onCreateGroupSelected() {
replaceFragment(CreateGroupFragment().apply { delegate = this@NewConversationFragment })
replaceFragment(CreateGroupFragment().also { it.delegate = this })
}
override fun onJoinCommunitySelected() {
replaceFragment(JoinCommunityFragment().apply { delegate = this@NewConversationFragment })
replaceFragment(JoinCommunityFragment().also { it.delegate = this })
}
override fun onContactSelected(address: String) {
@@ -80,6 +84,10 @@ class NewConversationFragment : BottomSheetDialogFragment(), NewConversationDele
childFragmentManager.popBackStack()
}
override fun onInviteFriend() {
replaceFragment(InviteFriendFragment().also { it.delegate = this })
}
override fun onDialogClosePressed() {
dismiss()
}

View File

@@ -1,70 +0,0 @@
package org.thoughtcrime.securesms.conversation.start
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentNewConversationHomeBinding
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.PublicKeyValidation
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.mms.GlideApp
import javax.inject.Inject
@AndroidEntryPoint
class NewConversationHomeFragment : Fragment() {
private lateinit var binding: FragmentNewConversationHomeBinding
private val viewModel: NewConversationHomeViewModel by viewModels()
@Inject
lateinit var textSecurePreferences: TextSecurePreferences
lateinit var delegate: NewConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentNewConversationHomeBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
binding.createPrivateChatButton.setOnClickListener { delegate.onNewMessageSelected() }
binding.createClosedGroupButton.setOnClickListener { delegate.onCreateGroupSelected() }
binding.joinCommunityButton.setOnClickListener { delegate.onJoinCommunitySelected() }
val adapter = ContactListAdapter(requireContext(), GlideApp.with(requireContext())) {
delegate.onContactSelected(it.address.serialize())
}
val unknownSectionTitle = getString(R.string.new_conversation_unknown_contacts_section_title)
val recipients = viewModel.recipients.value?.filter { !it.isGroupRecipient && it.address.serialize() != textSecurePreferences.getLocalNumber()!! } ?: emptyList()
val contactGroups = recipients.map {
val sessionId = it.address.serialize()
val contact = DatabaseComponent.get(requireContext()).sessionContactDatabase().getContactWithSessionID(sessionId)
val displayName = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionId
ContactListItem.Contact(it, displayName)
}.sortedBy { it.displayName }
.groupBy { if (PublicKeyValidation.isValid(it.displayName)) unknownSectionTitle else it.displayName.firstOrNull()?.uppercase() ?: unknownSectionTitle }
.toMutableMap()
contactGroups.remove(unknownSectionTitle)?.let { contactGroups.put(unknownSectionTitle, it) }
adapter.items = contactGroups.flatMap { entry -> listOf(ContactListItem.Header(entry.key)) + entry.value }
binding.contactsRecyclerView.adapter = adapter
val divider = ContextCompat.getDrawable(requireActivity(), R.drawable.conversation_menu_divider)!!.let {
DividerItemDecoration(requireActivity(), RecyclerView.VERTICAL).apply {
setDrawable(it)
}
}
binding.contactsRecyclerView.addItemDecoration(divider)
}
}

View File

@@ -1,35 +0,0 @@
package org.thoughtcrime.securesms.conversation.start
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.ThreadDatabase
import javax.inject.Inject
@HiltViewModel
class NewConversationHomeViewModel @Inject constructor(private val threadDb: ThreadDatabase): ViewModel() {
private val _recipients = MutableLiveData<List<Recipient>>()
val recipients: LiveData<List<Recipient>> = _recipients
init {
viewModelScope.launch {
threadDb.approvedConversationList.use { openCursor ->
val reader = threadDb.readerFor(openCursor)
val threads = mutableListOf<Recipient>()
while (true) {
threads += reader.next?.recipient ?: break
}
withContext(Dispatchers.Main) {
_recipients.value = threads
}
}
}
}
}

View File

@@ -0,0 +1,114 @@
package org.thoughtcrime.securesms.conversation.start.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import network.loki.messenger.R
import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.ItemButton
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.theme.ThemeColors
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.QrImage
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalType
@Composable
internal fun StartConversationScreen(
accountId: String,
delegate: StartConversationDelegate
) {
Column(modifier = Modifier.background(
LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small
)) {
AppBar(stringResource(R.string.dialog_start_conversation_title), onClose = delegate::onDialogClosePressed)
Surface(
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
color = LocalColors.current.backgroundSecondary
) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
ItemButton(
textId = R.string.messageNew,
icon = R.drawable.ic_message,
modifier = Modifier.contentDescription(R.string.AccessibilityId_new_direct_message),
onClick = delegate::onNewMessageSelected)
Divider(startIndent = LocalDimensions.current.dividerIndent)
ItemButton(
textId = R.string.activity_create_group_title,
icon = R.drawable.ic_group,
modifier = Modifier.contentDescription(R.string.AccessibilityId_create_group),
onClick = delegate::onCreateGroupSelected
)
Divider(startIndent = LocalDimensions.current.dividerIndent)
ItemButton(
textId = R.string.dialog_join_community_title,
icon = R.drawable.ic_globe,
modifier = Modifier.contentDescription(R.string.AccessibilityId_join_community),
onClick = delegate::onJoinCommunitySelected
)
Divider(startIndent = LocalDimensions.current.dividerIndent)
ItemButton(
textId = R.string.activity_settings_invite_button_title,
icon = R.drawable.ic_invite_friend,
Modifier.contentDescription(R.string.AccessibilityId_invite_friend_button),
onClick = delegate::onInviteFriend
)
Column(
modifier = Modifier
.padding(horizontal = LocalDimensions.current.spacing)
.padding(top = LocalDimensions.current.spacing)
.padding(bottom = LocalDimensions.current.spacing)
) {
Text(stringResource(R.string.accountIdYours), style = LocalType.current.xl)
Spacer(modifier = Modifier.height(LocalDimensions.current.xxsSpacing))
Text(
text = stringResource(R.string.qrYoursDescription),
color = LocalColors.current.textSecondary,
style = LocalType.current.small
)
Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing))
QrImage(
string = accountId,
Modifier.contentDescription(R.string.AccessibilityId_qr_code),
icon = R.drawable.session
)
}
}
}
}
}
@Preview
@Composable
private fun PreviewStartConversationScreen(
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(colors) {
StartConversationScreen(
accountId = "059287129387123",
NullStartConversationDelegate
)
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.conversation.start.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.collectAsState
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableStateFlow
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.start.NullStartConversationDelegate
import org.thoughtcrime.securesms.ui.createThemedComposeView
import javax.inject.Inject
@AndroidEntryPoint
class StartConversationHomeFragment : Fragment() {
@Inject
lateinit var textSecurePreferences: TextSecurePreferences
var delegate = MutableStateFlow<StartConversationDelegate>(NullStartConversationDelegate)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View = createThemedComposeView {
StartConversationScreen(
accountId = TextSecurePreferences.getLocalNumber(requireContext())!!,
delegate = delegate.collectAsState().value
)
}
}

View File

@@ -0,0 +1,93 @@
package org.thoughtcrime.securesms.conversation.start.invitefriend
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.SlimOutlineButton
import org.thoughtcrime.securesms.ui.components.SlimOutlineCopyButton
import org.thoughtcrime.securesms.ui.components.border
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalType
@Composable
internal fun InviteFriend(
accountId: String,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
copyPublicKey: () -> Unit = {},
sendInvitation: () -> Unit = {},
) {
Column(modifier = Modifier.background(
LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small
)) {
AppBar(stringResource(R.string.invite_a_friend), onBack = onBack, onClose = onClose)
Column(
modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing)
.padding(top = LocalDimensions.current.spacing),
) {
Text(
accountId,
modifier = Modifier
.contentDescription(R.string.AccessibilityId_account_id)
.fillMaxWidth()
.border()
.padding(LocalDimensions.current.spacing),
textAlign = TextAlign.Center,
style = LocalType.current.base
)
Spacer(modifier = Modifier.height(LocalDimensions.current.xsSpacing))
Text(
stringResource(R.string.invite_your_friend_to_chat_with_you_on_session_by_sharing_your_account_id_with_them),
textAlign = TextAlign.Center,
style = LocalType.current.small,
color = LocalColors.current.textSecondary,
modifier = Modifier.padding(horizontal = LocalDimensions.current.smallSpacing)
)
Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing))
Row(horizontalArrangement = spacedBy(LocalDimensions.current.smallSpacing)) {
SlimOutlineButton(
stringResource(R.string.share),
modifier = Modifier
.weight(1f)
.contentDescription("Share button"),
onClick = sendInvitation
)
SlimOutlineCopyButton(
modifier = Modifier.weight(1f),
onClick = copyPublicKey
)
}
}
}
}
@Preview
@Composable
private fun PreviewInviteFriend() {
PreviewTheme {
InviteFriend("050000000")
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.conversation.start.invitefriend
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.Fragment
import dagger.hilt.android.AndroidEntryPoint
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.preferences.copyPublicKey
import org.thoughtcrime.securesms.preferences.sendInvitationToUseSession
import org.thoughtcrime.securesms.ui.createThemedComposeView
@AndroidEntryPoint
class InviteFriendFragment : Fragment() {
lateinit var delegate: StartConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View = createThemedComposeView {
InviteFriend(
TextSecurePreferences.getLocalNumber(LocalContext.current)!!,
onBack = { delegate.onDialogBackPressed() },
onClose = { delegate.onDialogClosePressed() },
copyPublicKey = requireContext()::copyPublicKey,
sendInvitation = requireContext()::sendInvitationToUseSession,
)
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
internal interface Callbacks {
fun onChange(value: String) {}
fun onContinue() {}
fun onScanQrCode(value: String) {}
}

View File

@@ -0,0 +1,211 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
import android.graphics.Rect
import android.os.Build
import android.view.ViewTreeObserver
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import network.loki.messenger.R
import org.thoughtcrime.securesms.conversation.start.StartConversationFragment.Companion.PEEK_RATIO
import org.thoughtcrime.securesms.ui.LoadingArcOr
import org.thoughtcrime.securesms.ui.components.AppBar
import org.thoughtcrime.securesms.ui.components.BorderlessButtonWithIcon
import org.thoughtcrime.securesms.ui.components.MaybeScanQrCode
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.SessionOutlinedTextField
import org.thoughtcrime.securesms.ui.components.SessionTabRow
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.theme.ThemeColors
import kotlin.math.max
private val TITLES = listOf(R.string.enter_account_id, R.string.qrScan)
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun NewMessage(
state: State,
qrErrors: Flow<String> = emptyFlow(),
callbacks: Callbacks = object: Callbacks {},
onClose: () -> Unit = {},
onBack: () -> Unit = {},
onHelp: () -> Unit = {},
) {
val pagerState = rememberPagerState { TITLES.size }
Column(modifier = Modifier.background(
LocalColors.current.backgroundSecondary,
shape = MaterialTheme.shapes.small
)) {
AppBar(stringResource(R.string.messageNew), onClose = onClose, onBack = onBack)
SessionTabRow(pagerState, TITLES)
HorizontalPager(pagerState) {
when (TITLES[it]) {
R.string.enter_account_id -> EnterAccountId(state, callbacks, onHelp)
R.string.qrScan -> MaybeScanQrCode(qrErrors, onScan = callbacks::onScanQrCode)
}
}
}
}
@Composable
private fun EnterAccountId(
state: State,
callbacks: Callbacks,
onHelp: () -> Unit = {}
) {
// the scaffold is required to provide the contentPadding. That contentPadding is needed
// to properly handle the ime padding.
Scaffold() { contentPadding ->
// we need this extra surface to handle nested scrolling properly,
// because this scrollable component is inside a bottomSheet dialog which is itself scrollable
Surface(
modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection()),
color = LocalColors.current.backgroundSecondary
) {
var accountModifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
// There is a known issue with the ime padding on android versions below 30
/// So on these older versions we need to resort to some manual padding based on the visible height
// when the keyboard is up
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
val keyboardHeight by keyboardHeight()
accountModifier = accountModifier.padding(bottom = keyboardHeight)
} else {
accountModifier = accountModifier
.consumeWindowInsets(contentPadding)
.imePadding()
}
Column(
modifier = accountModifier
) {
Column(
modifier = Modifier.padding(vertical = LocalDimensions.current.spacing),
horizontalAlignment = Alignment.CenterHorizontally,
) {
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
.padding(horizontal = LocalDimensions.current.spacing),
contentDescription = "Session id input box",
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,
error = state.error?.string(),
isTextErrorColor = state.isTextErrorColor
)
Spacer(modifier = Modifier.height(LocalDimensions.current.xxxsSpacing))
BorderlessButtonWithIcon(
text = stringResource(R.string.messageNewDescription),
modifier = Modifier
.contentDescription(R.string.AccessibilityId_help_desk_link)
.padding(horizontal = LocalDimensions.current.mediumSpacing)
.fillMaxWidth(),
style = LocalType.current.small,
color = LocalColors.current.textSecondary,
iconRes = R.drawable.ic_circle_question_mark,
onClick = onHelp
)
}
Spacer(modifier = Modifier.height(LocalDimensions.current.smallSpacing))
Spacer(Modifier.weight(2f))
PrimaryOutlineButton(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(horizontal = LocalDimensions.current.xlargeSpacing)
.padding(bottom = LocalDimensions.current.smallSpacing)
.fillMaxWidth()
.contentDescription(R.string.next),
enabled = state.isNextButtonEnabled,
onClick = callbacks::onContinue
) {
LoadingArcOr(state.loading) {
Text(stringResource(R.string.next))
}
}
}
}
}
}
@Composable
fun keyboardHeight(): MutableState<Dp> {
val view = LocalView.current
var keyboardHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
DisposableEffect(view) {
val listener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height * PEEK_RATIO
val keypadHeightPx = max( screenHeight - rect.bottom, 0f)
keyboardHeight.value = with(density) { keypadHeightPx.toDp() }
}
view.viewTreeObserver.addOnGlobalLayoutListener(listener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(listener)
}
}
return keyboardHeight
}
@Preview
@Composable
private fun PreviewNewMessage(
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(colors) {
NewMessage(State("z"))
}
}

View File

@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.openUrl
import org.thoughtcrime.securesms.ui.createThemedComposeView
class NewMessageFragment : Fragment() {
private val viewModel: NewMessageViewModel by viewModels()
lateinit var delegate: StartConversationDelegate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
viewModel.success.collect {
createPrivateChat(it.publicKey)
}
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View = createThemedComposeView {
val uiState by viewModel.state.collectAsState(State())
NewMessage(
uiState,
viewModel.qrErrors,
viewModel,
onClose = { delegate.onDialogClosePressed() },
onBack = { delegate.onDialogBackPressed() },
onHelp = { requireContext().openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") }
)
}
private fun createPrivateChat(hexEncodedPublicKey: String) {
val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false)
Intent(requireContext(), ConversationActivityV2::class.java).apply {
putExtra(ConversationActivityV2.ADDRESS, recipient.address)
setDataAndType(requireActivity().intent.data, requireActivity().intent.type)
putExtra(ConversationActivityV2.THREAD_ID, DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient))
}.let(requireContext()::startActivity)
delegate.onDialogClosePressed()
}
}

View File

@@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.conversation.start.newmessage
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import network.loki.messenger.R
import org.session.libsession.snode.SnodeAPI
import org.session.libsignal.utilities.PublicKeyValidation
import org.session.libsignal.utilities.timeout
import org.thoughtcrime.securesms.ui.GetString
import java.util.concurrent.TimeoutException
import javax.inject.Inject
@HiltViewModel
internal class NewMessageViewModel @Inject constructor(
private val application: Application
): AndroidViewModel(application), Callbacks {
private val _state = MutableStateFlow(State())
val state = _state.asStateFlow()
private val _success = MutableSharedFlow<Success>()
val success get() = _success.asSharedFlow()
private val _qrErrors = MutableSharedFlow<String>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val qrErrors = _qrErrors.asSharedFlow()
private var loadOnsJob: Job? = null
override fun onChange(value: String) {
loadOnsJob?.cancel()
loadOnsJob = null
_state.update { it.copy(newMessageIdOrOns = value, isTextErrorColor = false, loading = false) }
}
override fun onContinue() {
val idOrONS = state.value.newMessageIdOrOns.trim()
if (PublicKeyValidation.isValid(idOrONS, isPrefixRequired = false)) {
onUnvalidatedPublicKey(publicKey = idOrONS)
} else {
resolveONS(ons = idOrONS)
}
}
override fun onScanQrCode(value: String) {
if (PublicKeyValidation.isValid(value, isPrefixRequired = false) && PublicKeyValidation.hasValidPrefix(value)) {
onPublicKey(value)
} else {
_qrErrors.tryEmit(application.getString(R.string.this_qr_code_does_not_contain_an_account_id))
}
}
private fun resolveONS(ons: String) {
if (loadOnsJob?.isActive == true) return
// This could be an ONS name
_state.update { it.copy(isTextErrorColor = false, error = null, loading = true) }
loadOnsJob = viewModelScope.launch(Dispatchers.IO) {
try {
val publicKey = SnodeAPI.getAccountID(ons).timeout(30_000).get()
if (isActive) onPublicKey(publicKey)
} catch (e: Exception) {
if (isActive) onError(e)
}
}
}
private fun onError(e: Exception) {
_state.update { it.copy(loading = false, isTextErrorColor = true, error = GetString(e) { it.toMessage() }) }
}
private fun onPublicKey(publicKey: String) {
_state.update { it.copy(loading = false) }
viewModelScope.launch { _success.emit(Success(publicKey)) }
}
private fun onUnvalidatedPublicKey(publicKey: String) {
if (PublicKeyValidation.hasValidPrefix(publicKey)) {
onPublicKey(publicKey)
} else {
_state.update { it.copy(isTextErrorColor = true, error = GetString(R.string.accountIdErrorInvalid), loading = false) }
}
}
private fun Exception.toMessage() = when (this) {
is SnodeAPI.Error.Generic -> application.getString(R.string.onsErrorNotRecognized)
is TimeoutException -> application.getString(R.string.onsErrorUnableToSearch)
else -> application.getString(R.string.fragment_enter_public_key_error_message)
}
}
internal data class State(
val newMessageIdOrOns: String = "",
val isTextErrorColor: Boolean = false,
val error: GetString? = null,
val loading: Boolean = false
) {
val isNextButtonEnabled: Boolean get() = newMessageIdOrOns.isNotBlank()
}
internal data class Success(val publicKey: String)

View File

@@ -70,7 +70,7 @@ class ConversationAdapter(
@WorkerThread
private fun getSenderInfo(sender: String): Contact? {
return contactDB.getContactWithSessionID(sender)
return contactDB.getContactWithAccountID(sender)
}
sealed class ViewType(val rawValue: Int) {

View File

@@ -539,13 +539,14 @@ class ConversationReactionOverlay : FrameLayout {
if (!containsControlMessage && hasText) {
items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) })
}
// Copy Session ID
// Copy Account ID
if (recipient.isGroupRecipient && !recipient.isCommunityRecipient && message.recipient.address.toString() != userPublicKey) {
items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_session_id, { handleActionItemClicked(Action.COPY_SESSION_ID) })
items += ActionItem(R.attr.menu_copy_icon, R.string.activity_conversation_menu_copy_account_id, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) })
}
// Delete message
if (userCanDeleteSelectedItems(context, message, openGroup, userPublicKey, blindedPublicKey)) {
items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) }, R.string.AccessibilityId_delete_message, message.subtitle, R.color.destructive)
items += ActionItem(R.attr.menu_trash_icon, R.string.delete, { handleActionItemClicked(Action.DELETE) },
R.string.AccessibilityId_delete_message, message.subtitle, ThemeUtil.getThemedColor(context, R.attr.danger))
}
// Ban user
if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) {
@@ -689,7 +690,7 @@ class ConversationReactionOverlay : FrameLayout {
RESYNC,
DOWNLOAD,
COPY_MESSAGE,
COPY_SESSION_ID,
COPY_ACCOUNT_ID,
VIEW_INFO,
SELECT,
DELETE,

View File

@@ -18,7 +18,7 @@ import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
@@ -77,7 +77,7 @@ class ConversationViewModel(
val blindedPublicKey: String?
get() = if (openGroup == null || edKeyPair == null || !serverCapabilities.contains(OpenGroupApi.Capability.BLIND.name.lowercase())) null else {
SodiumUtilities.blindedKeyPair(openGroup!!.publicKey, edKeyPair)?.publicKey?.asBytes
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
}
val isMessageRequestThread : Boolean

View File

@@ -26,7 +26,7 @@ class DeleteOptionsBottomSheet : BottomSheetDialogFragment(), View.OnClickListen
val contact by lazy {
val senderId = recipient.address.serialize()
// this dialog won't show for open group contacts
contactDatabase.getContactWithSessionID(senderId)
contactDatabase.getContactWithAccountID(senderId)
?.displayName(Contact.ContactContext.REGULAR)
}

View File

@@ -27,10 +27,9 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -38,15 +37,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
@@ -59,7 +54,6 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
import org.thoughtcrime.securesms.MediaPreviewActivity.getPreviewIntent
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.ui.AppTheme
import org.thoughtcrime.securesms.ui.Avatar
import org.thoughtcrime.securesms.ui.CarouselNextButton
import org.thoughtcrime.securesms.ui.CarouselPrevButton
@@ -69,13 +63,19 @@ import org.thoughtcrime.securesms.ui.CellWithPaddingAndMargin
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.HorizontalPagerIndicator
import org.thoughtcrime.securesms.ui.ItemButton
import org.thoughtcrime.securesms.ui.PreviewTheme
import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider
import org.thoughtcrime.securesms.ui.LargeItemButton
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.TitledText
import org.thoughtcrime.securesms.ui.blackAlpha40
import org.thoughtcrime.securesms.ui.colorDestructive
import org.thoughtcrime.securesms.ui.destructiveButtonColors
import org.thoughtcrime.securesms.ui.theme.ThemeColors
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.blackAlpha40
import org.thoughtcrime.securesms.ui.theme.dangerButtonColors
import org.thoughtcrime.securesms.ui.setComposeContent
import org.thoughtcrime.securesms.ui.theme.LocalType
import org.thoughtcrime.securesms.ui.theme.bold
import org.thoughtcrime.securesms.ui.theme.monospace
import javax.inject.Inject
@AndroidEntryPoint
@@ -102,9 +102,7 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
viewModel.timestamp = intent.getLongExtra(MESSAGE_TIMESTAMP, -1L)
ComposeView(this)
.apply { setContent { MessageDetailsScreen() } }
.let(::setContentView)
setComposeContent { MessageDetailsScreen() }
lifecycleScope.launch {
viewModel.eventFlow.collect {
@@ -121,16 +119,14 @@ class MessageDetailActivity : PassphraseRequiredActionBarActivity() {
@Composable
private fun MessageDetailsScreen() {
val state by viewModel.stateFlow.collectAsState()
AppTheme {
MessageDetails(
state = state,
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) },
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
)
}
MessageDetails(
state = state,
onReply = if (state.canReply) { { setResultAndFinish(ON_REPLY) } } else null,
onResend = state.error?.let { { setResultAndFinish(ON_RESEND) } },
onDelete = { setResultAndFinish(ON_DELETE) },
onClickImage = { viewModel.onClickImage(it) },
onAttachmentNeedsDownload = viewModel::onAttachmentNeedsDownload,
)
}
private fun setResultAndFinish(code: Int) {
@@ -155,12 +151,12 @@ fun MessageDetails(
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.padding(vertical = LocalDimensions.current.smallSpacing),
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)
) {
state.record?.let { message ->
AndroidView(
modifier = Modifier.padding(horizontal = 32.dp),
modifier = Modifier.padding(horizontal = LocalDimensions.current.spacing),
factory = {
ViewVisibleMessageContentBinding.inflate(LayoutInflater.from(it)).mainContainerConstraint.apply {
bind(
@@ -196,7 +192,7 @@ fun CellMetadata(
state.apply {
if (listOfNotNull(sent, received, error, senderInfo).isEmpty()) return
CellWithPaddingAndMargin {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)) {
TitledText(sent)
TitledText(received)
TitledErrorText(error)
@@ -222,25 +218,25 @@ fun CellButtons(
Cell {
Column {
onReply?.let {
ItemButton(
stringResource(R.string.reply),
LargeItemButton(
R.string.reply,
R.drawable.ic_message_details__reply,
onClick = it
)
Divider()
}
onResend?.let {
ItemButton(
stringResource(R.string.resend),
LargeItemButton(
R.string.resend,
R.drawable.ic_message_details__refresh,
onClick = it
)
Divider()
}
ItemButton(
stringResource(R.string.delete),
LargeItemButton(
R.string.delete,
R.drawable.ic_message_details__trash,
colors = destructiveButtonColors(),
colors = dangerButtonColors(),
onClick = onDelete
)
}
@@ -254,7 +250,7 @@ fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
val pagerState = rememberPagerState { attachments.size }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)) {
Row {
CarouselPrevButton(pagerState)
Box(modifier = Modifier.weight(1f)) {
@@ -263,7 +259,7 @@ fun Carousel(attachments: List<Attachment>, onClick: (Int) -> Unit) {
ExpandButton(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.padding(LocalDimensions.current.xxsSpacing)
) { onClick(pagerState.currentPage) }
}
CarouselNextButton(pagerState)
@@ -316,9 +312,9 @@ fun ExpandButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
@Preview
@Composable
fun PreviewMessageDetails(
@PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(themeResId) {
PreviewTheme(colors) {
MessageDetails(
state = MessageDetailsState(
nonImageAttachmentFileDetails = listOf(
@@ -341,10 +337,10 @@ fun PreviewMessageDetails(
fun FileDetails(fileDetails: List<TitledText>) {
if (fileDetails.isEmpty()) return
CellWithPaddingAndMargin(padding = 0.dp) {
Cell {
FlowRow(
modifier = Modifier.padding(vertical = 24.dp, horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
modifier = Modifier.padding(horizontal = LocalDimensions.current.xsSpacing, vertical = LocalDimensions.current.spacing),
verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.smallSpacing)
) {
fileDetails.forEach {
BoxWithConstraints {
@@ -352,7 +348,7 @@ fun FileDetails(fileDetails: List<TitledText>) {
it,
modifier = Modifier
.widthIn(min = maxWidth.div(2))
.padding(horizontal = 12.dp)
.padding(horizontal = LocalDimensions.current.xsSpacing)
.width(IntrinsicSize.Max)
)
}
@@ -365,7 +361,8 @@ fun FileDetails(fileDetails: List<TitledText>) {
fun TitledErrorText(titledText: TitledText?) {
TitledText(
titledText,
valueStyle = LocalTextStyle.current.copy(color = colorDestructive)
style = LocalType.current.base,
color = LocalColors.current.danger
)
}
@@ -373,7 +370,7 @@ fun TitledErrorText(titledText: TitledText?) {
fun TitledMonospaceText(titledText: TitledText?) {
TitledText(
titledText,
valueStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace)
style = LocalType.current.base.monospace()
)
}
@@ -381,24 +378,25 @@ fun TitledMonospaceText(titledText: TitledText?) {
fun TitledText(
titledText: TitledText?,
modifier: Modifier = Modifier,
valueStyle: TextStyle = LocalTextStyle.current,
style: TextStyle = LocalType.current.base,
color: Color = Color.Unspecified
) {
titledText?.apply {
TitledView(title, modifier) {
Text(text, style = valueStyle, modifier = Modifier.fillMaxWidth())
Text(
text,
style = style,
color = color,
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun TitledView(title: GetString, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Title(title)
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(LocalDimensions.current.xxxsSpacing)) {
Text(title.string(), style = LocalType.current.base.bold())
content()
}
}
@Composable
fun Title(title: GetString) {
Text(title.string(), fontWeight = FontWeight.Bold)
}

View File

@@ -1,381 +0,0 @@
/*
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.conversation.v2;
import android.annotation.TargetApi;
import android.app.ActivityManager;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import com.google.android.mms.pdu_alt.CharacterSets;
import com.google.android.mms.pdu_alt.EncodedStringValue;
import org.session.libsignal.utilities.Log;
import org.thoughtcrime.securesms.components.ComposeText;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import network.loki.messenger.R;
public class Util {
private static final String TAG = Log.tag(Util.class);
private static final long BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90);
public static <T> List<T> asList(T... elements) {
List<T> result = new LinkedList<>();
Collections.addAll(result, elements);
return result;
}
public static String join(String[] list, String delimiter) {
return join(Arrays.asList(list), delimiter);
}
public static <T> String join(Collection<T> list, String delimiter) {
StringBuilder result = new StringBuilder();
int i = 0;
for (T item : list) {
result.append(item);
if (++i < list.size())
result.append(delimiter);
}
return result.toString();
}
public static String join(long[] list, String delimeter) {
List<Long> boxed = new ArrayList<>(list.length);
for (int i = 0; i < list.length; i++) {
boxed.add(list[i]);
}
return join(boxed, delimeter);
}
@SafeVarargs
public static @NonNull <E> List<E> join(@NonNull List<E>... lists) {
int totalSize = Stream.of(lists).reduce(0, (sum, list) -> sum + list.size());
List<E> joined = new ArrayList<>(totalSize);
for (List<E> list : lists) {
joined.addAll(list);
}
return joined;
}
public static String join(List<Long> list, String delimeter) {
StringBuilder sb = new StringBuilder();
for (int j = 0; j < list.size(); j++) {
if (j != 0) sb.append(delimeter);
sb.append(list.get(j));
}
return sb.toString();
}
public static String rightPad(String value, int length) {
if (value.length() >= length) {
return value;
}
StringBuilder out = new StringBuilder(value);
while (out.length() < length) {
out.append(" ");
}
return out.toString();
}
public static boolean isEmpty(EncodedStringValue[] value) {
return value == null || value.length == 0;
}
public static boolean isEmpty(ComposeText value) {
return value == null || value.getText() == null || TextUtils.isEmpty(value.getTextTrimmed());
}
public static boolean isEmpty(Collection<?> collection) {
return collection == null || collection.isEmpty();
}
public static boolean isEmpty(@Nullable CharSequence charSequence) {
return charSequence == null || charSequence.length() == 0;
}
public static boolean hasItems(@Nullable Collection<?> collection) {
return collection != null && !collection.isEmpty();
}
public static <K, V> V getOrDefault(@NonNull Map<K, V> map, K key, V defaultValue) {
return map.containsKey(key) ? map.get(key) : defaultValue;
}
public static String getFirstNonEmpty(String... values) {
for (String value : values) {
if (!Util.isEmpty(value)) {
return value;
}
}
return "";
}
public static @NonNull String emptyIfNull(@Nullable String value) {
return value != null ? value : "";
}
public static @NonNull CharSequence emptyIfNull(@Nullable CharSequence value) {
return value != null ? value : "";
}
public static CharSequence getBoldedString(String value) {
SpannableString spanned = new SpannableString(value);
spanned.setSpan(new StyleSpan(Typeface.BOLD), 0,
spanned.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spanned;
}
public static @NonNull String toIsoString(byte[] bytes) {
try {
return new String(bytes, CharacterSets.MIMENAME_ISO_8859_1);
} catch (UnsupportedEncodingException e) {
throw new AssertionError("ISO_8859_1 must be supported!");
}
}
public static byte[] toIsoBytes(String isoString) {
try {
return isoString.getBytes(CharacterSets.MIMENAME_ISO_8859_1);
} catch (UnsupportedEncodingException e) {
throw new AssertionError("ISO_8859_1 must be supported!");
}
}
public static byte[] toUtf8Bytes(String utf8String) {
try {
return utf8String.getBytes(CharacterSets.MIMENAME_UTF_8);
} catch (UnsupportedEncodingException e) {
throw new AssertionError("UTF_8 must be supported!");
}
}
public static void wait(Object lock, long timeout) {
try {
lock.wait(timeout);
} catch (InterruptedException ie) {
throw new AssertionError(ie);
}
}
public static List<String> split(String source, String delimiter) {
List<String> results = new LinkedList<>();
if (TextUtils.isEmpty(source)) {
return results;
}
String[] elements = source.split(delimiter);
Collections.addAll(results, elements);
return results;
}
public static byte[][] split(byte[] input, int firstLength, int secondLength) {
byte[][] parts = new byte[2][];
parts[0] = new byte[firstLength];
System.arraycopy(input, 0, parts[0], 0, firstLength);
parts[1] = new byte[secondLength];
System.arraycopy(input, firstLength, parts[1], 0, secondLength);
return parts;
}
public static byte[] combine(byte[]... elements) {
try {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
for (byte[] element : elements) {
baos.write(element);
}
return baos.toByteArray();
} catch (IOException e) {
throw new AssertionError(e);
}
}
public static byte[] trim(byte[] input, int length) {
byte[] result = new byte[length];
System.arraycopy(input, 0, result, 0, result.length);
return result;
}
public static byte[] getSecretBytes(int size) {
return getSecretBytes(new SecureRandom(), size);
}
public static byte[] getSecretBytes(@NonNull SecureRandom secureRandom, int size) {
byte[] secret = new byte[size];
secureRandom.nextBytes(secret);
return secret;
}
public static <T> T getRandomElement(T[] elements) {
return elements[new SecureRandom().nextInt(elements.length)];
}
public static <T> T getRandomElement(List<T> elements) {
return elements.get(new SecureRandom().nextInt(elements.size()));
}
public static boolean equals(@Nullable Object a, @Nullable Object b) {
return a == b || (a != null && a.equals(b));
}
public static int hashCode(@Nullable Object... objects) {
return Arrays.hashCode(objects);
}
public static @Nullable Uri uri(@Nullable String uri) {
if (uri == null) return null;
else return Uri.parse(uri);
}
@TargetApi(VERSION_CODES.KITKAT)
public static boolean isLowMemory(Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice()) ||
activityManager.getLargeMemoryClass() <= 64;
}
public static int clamp(int value, int min, int max) {
return Math.min(Math.max(value, min), max);
}
public static long clamp(long value, long min, long max) {
return Math.min(Math.max(value, min), max);
}
public static float clamp(float value, float min, float max) {
return Math.min(Math.max(value, min), max);
}
/**
* Returns half of the difference between the given length, and the length when scaled by the
* given scale.
*/
public static float halfOffsetFromScale(int length, float scale) {
float scaledLength = length * scale;
return (length - scaledLength) / 2;
}
public static @Nullable String readTextFromClipboard(@NonNull Context context) {
{
ClipboardManager clipboardManager = (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboardManager.hasPrimaryClip() && clipboardManager.getPrimaryClip().getItemCount() > 0) {
return clipboardManager.getPrimaryClip().getItemAt(0).getText().toString();
} else {
return null;
}
}
}
public static void writeTextToClipboard(@NonNull Context context, @NonNull String text) {
writeTextToClipboard(context, context.getString(R.string.app_name), text);
}
public static void writeTextToClipboard(@NonNull Context context, @NonNull String label, @NonNull String text) {
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
ClipData clip = ClipData.newPlainText(label, text);
clipboard.setPrimaryClip(clip);
}
public static int toIntExact(long value) {
if ((int)value != value) {
throw new ArithmeticException("integer overflow");
}
return (int)value;
}
public static boolean isEquals(@Nullable Long first, long second) {
return first != null && first == second;
}
@SafeVarargs
public static <T> List<T> concatenatedList(Collection <T>... items) {
final List<T> concat = new ArrayList<>(Stream.of(items).reduce(0, (sum, list) -> sum + list.size()));
for (Collection<T> list : items) {
concat.addAll(list);
}
return concat;
}
public static boolean isLong(String value) {
try {
Long.parseLong(value);
return true;
} catch (NumberFormatException e) {
return false;
}
}
public static int parseInt(String integer, int defaultValue) {
try {
return Integer.parseInt(integer);
} catch (NumberFormatException e) {
return defaultValue;
}
}
}

View File

@@ -0,0 +1,384 @@
/*
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.conversation.v2
import android.annotation.TargetApi
import android.app.ActivityManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Typeface
import android.net.Uri
import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import android.text.Spannable
import android.text.SpannableString
import android.text.TextUtils
import android.text.style.StyleSpan
import android.view.View
import com.annimon.stream.Stream
import com.google.android.mms.pdu_alt.CharacterSets
import com.google.android.mms.pdu_alt.EncodedStringValue
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.UnsupportedEncodingException
import java.security.SecureRandom
import java.util.Arrays
import java.util.Collections
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.min
import network.loki.messenger.R
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.components.ComposeText
object Util {
private val TAG: String = Log.tag(Util::class.java)
private val BUILD_LIFESPAN = TimeUnit.DAYS.toMillis(90)
fun <T> asList(vararg elements: T): List<T> {
val result = mutableListOf<T>() // LinkedList()
Collections.addAll(result, *elements)
return result
}
fun join(list: Array<String?>, delimiter: String?): String {
return join(listOf(*list), delimiter)
}
fun <T> join(list: Collection<T>, delimiter: String?): String {
val result = StringBuilder()
var i = 0
for (item in list) {
result.append(item)
if (++i < list.size) result.append(delimiter)
}
return result.toString()
}
fun join(list: LongArray, delimeter: String?): String {
val boxed: MutableList<Long> = ArrayList(list.size)
for (i in list.indices) {
boxed.add(list[i])
}
return join(boxed, delimeter)
}
@SafeVarargs
fun <E> join(vararg lists: List<E>): List<E> {
val totalSize = Stream.of(*lists).reduce(0) { sum: Int, list: List<E> -> sum + list.size }
val joined: MutableList<E> = ArrayList(totalSize)
for (list in lists) {
joined.addAll(list)
}
return joined
}
fun join(list: List<Long>, delimeter: String?): String {
val sb = StringBuilder()
for (j in list.indices) {
if (j != 0) sb.append(delimeter)
sb.append(list[j])
}
return sb.toString()
}
fun rightPad(value: String, length: Int): String {
if (value.length >= length) {
return value
}
val out = StringBuilder(value)
while (out.length < length) {
out.append(" ")
}
return out.toString()
}
fun isEmpty(value: Array<EncodedStringValue?>?): Boolean {
return value == null || value.size == 0
}
fun isEmpty(value: ComposeText?): Boolean {
return value == null || value.text == null || TextUtils.isEmpty(value.textTrimmed)
}
fun isEmpty(collection: Collection<*>?): Boolean {
return collection == null || collection.isEmpty()
}
fun isEmpty(charSequence: CharSequence?): Boolean {
return charSequence == null || charSequence.length == 0
}
fun hasItems(collection: Collection<*>?): Boolean {
return collection != null && !collection.isEmpty()
}
fun <K, V> getOrDefault(map: Map<K, V>, key: K, defaultValue: V): V? {
return if (map.containsKey(key)) map[key] else defaultValue
}
fun getFirstNonEmpty(vararg values: String?): String {
for (value in values) {
if (!value.isNullOrEmpty()) { return value }
}
return ""
}
fun emptyIfNull(value: String?): String {
return value ?: ""
}
fun emptyIfNull(value: CharSequence?): CharSequence {
return value ?: ""
}
fun getBoldedString(value: String?): CharSequence {
val spanned = SpannableString(value)
spanned.setSpan(
StyleSpan(Typeface.BOLD), 0,
spanned.length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
return spanned
}
fun toIsoString(bytes: ByteArray?): String {
try {
return String(bytes!!, charset(CharacterSets.MIMENAME_ISO_8859_1))
} catch (e: UnsupportedEncodingException) {
throw AssertionError("ISO_8859_1 must be supported!")
}
}
fun toIsoBytes(isoString: String): ByteArray {
try {
return isoString.toByteArray(charset(CharacterSets.MIMENAME_ISO_8859_1))
} catch (e: UnsupportedEncodingException) {
throw AssertionError("ISO_8859_1 must be supported!")
}
}
fun toUtf8Bytes(utf8String: String): ByteArray {
try {
return utf8String.toByteArray(charset(CharacterSets.MIMENAME_UTF_8))
} catch (e: UnsupportedEncodingException) {
throw AssertionError("UTF_8 must be supported!")
}
}
fun wait(lock: Any, timeout: Long) {
try {
(lock as Object).wait(timeout)
} catch (ie: InterruptedException) {
throw AssertionError(ie)
}
}
fun split(source: String, delimiter: String): List<String> {
val results = mutableListOf<String>()
if (TextUtils.isEmpty(source)) {
return results
}
val elements =
source.split(delimiter.toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
Collections.addAll(results, *elements)
return results
}
fun split(input: ByteArray?, firstLength: Int, secondLength: Int): Array<ByteArray?> {
val parts = arrayOfNulls<ByteArray>(2)
parts[0] = ByteArray(firstLength)
System.arraycopy(input, 0, parts[0], 0, firstLength)
parts[1] = ByteArray(secondLength)
System.arraycopy(input, firstLength, parts[1], 0, secondLength)
return parts
}
fun combine(vararg elements: ByteArray?): ByteArray {
try {
val baos = ByteArrayOutputStream()
for (element in elements) {
baos.write(element)
}
return baos.toByteArray()
} catch (e: IOException) {
throw AssertionError(e)
}
}
fun trim(input: ByteArray?, length: Int): ByteArray {
val result = ByteArray(length)
System.arraycopy(input, 0, result, 0, result.size)
return result
}
fun getSecretBytes(size: Int): ByteArray {
return getSecretBytes(SecureRandom(), size)
}
fun getSecretBytes(secureRandom: SecureRandom, size: Int): ByteArray {
val secret = ByteArray(size)
secureRandom.nextBytes(secret)
return secret
}
fun <T> getRandomElement(elements: Array<T>): T {
return elements[SecureRandom().nextInt(elements.size)]
}
fun <T> getRandomElement(elements: List<T>): T {
return elements[SecureRandom().nextInt(elements.size)]
}
fun equals(a: Any?, b: Any?): Boolean {
return a === b || (a != null && a == b)
}
fun hashCode(vararg objects: Any?): Int {
return objects.contentHashCode()
}
fun uri(uri: String?): Uri? {
return if (uri == null) null
else Uri.parse(uri)
}
@TargetApi(VERSION_CODES.KITKAT)
fun isLowMemory(context: Context): Boolean {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
return (VERSION.SDK_INT >= VERSION_CODES.KITKAT && activityManager.isLowRamDevice) ||
activityManager.largeMemoryClass <= 64
}
fun clamp(value: Int, min: Int, max: Int): Int {
return min(max(value.toDouble(), min.toDouble()), max.toDouble()).toInt()
}
fun clamp(value: Long, min: Long, max: Long): Long {
return min(max(value.toDouble(), min.toDouble()), max.toDouble()).toLong()
}
fun clamp(value: Float, min: Float, max: Float): Float {
return min(max(value.toDouble(), min.toDouble()), max.toDouble()).toFloat()
}
/**
* Returns half of the difference between the given length, and the length when scaled by the
* given scale.
*/
fun halfOffsetFromScale(length: Int, scale: Float): Float {
val scaledLength = length * scale
return (length - scaledLength) / 2
}
fun readTextFromClipboard(context: Context): String? {
run {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
return if (clipboardManager.hasPrimaryClip() && clipboardManager.primaryClip!!.itemCount > 0) {
clipboardManager.primaryClip!!.getItemAt(0).text.toString()
} else {
null
}
}
}
fun writeTextToClipboard(context: Context, text: String) {
writeTextToClipboard(context, context.getString(R.string.app_name), text)
}
fun writeTextToClipboard(context: Context, label: String, text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText(label, text)
clipboard.setPrimaryClip(clip)
}
fun toIntExact(value: Long): Int {
if (value.toInt().toLong() != value) {
throw ArithmeticException("integer overflow")
}
return value.toInt()
}
fun isEquals(first: Long?, second: Long): Boolean {
return first != null && first == second
}
@SafeVarargs
fun <T> concatenatedList(vararg items: Collection<T>): List<T> {
val concat: MutableList<T> = ArrayList(
Stream.of(*items).reduce(0) { sum: Int, list: Collection<T> -> sum + list.size })
for (list in items) {
concat.addAll(list)
}
return concat
}
fun isLong(value: String): Boolean {
try {
value.toLong()
return true
} catch (e: NumberFormatException) {
return false
}
}
fun parseInt(integer: String, defaultValue: Int): Int {
return try {
integer.toInt()
} catch (e: NumberFormatException) {
defaultValue
}
}
// Method to determine if we're currently in a left-to-right or right-to-left language like Arabic
fun usingRightToLeftLanguage(context: Context): Boolean {
val config = context.resources.configuration
return config.layoutDirection == View.LAYOUT_DIRECTION_RTL
}
// Method to determine if we're currently in a left-to-right or right-to-left language like Arabic
fun usingLeftToRightLanguage(context: Context): Boolean {
val config = context.resources.configuration
return config.layoutDirection == View.LAYOUT_DIRECTION_LTR
}
}

View File

@@ -20,9 +20,9 @@ class BlockedDialog(private val recipient: Recipient, private val context: Conte
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val contactDB = DatabaseComponent.get(requireContext()).sessionContactDatabase()
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
val explanation = resources.getString(R.string.dialog_blocked_explanation, name)
val spannable = SpannableStringBuilder(explanation)

View File

@@ -26,9 +26,9 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
@Inject lateinit var contactDB: SessionContactDatabase
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: sessionID
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID)
val name = contact?.displayName(Contact.ContactContext.REGULAR) ?: accountID
title(resources.getString(R.string.dialog_download_title, name))
val explanation = resources.getString(R.string.dialog_download_explanation, name)
@@ -42,8 +42,8 @@ class DownloadDialog(private val recipient: Recipient) : DialogFragment() {
}
private fun trust() {
val sessionID = recipient.address.toString()
val contact = contactDB.getContactWithSessionID(sessionID) ?: return
val accountID = recipient.address.toString()
val contact = contactDB.getContactWithAccountID(accountID) ?: return
val threadID = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
contactDB.setContactIsTrusted(contact, true, threadID)
JobQueue.shared.resumePendingJobs(AttachmentDownloadJob.KEY)

View File

@@ -1,23 +0,0 @@
package org.thoughtcrime.securesms.conversation.v2.dialogs
import android.app.Dialog
import android.os.Bundle
import androidx.fragment.app.DialogFragment
import network.loki.messenger.R
import org.thoughtcrime.securesms.createSessionDialog
/** Shown if the user is about to send their recovery phrase to someone. */
class SendSeedDialog(private val proceed: (() -> Unit)? = null) : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = createSessionDialog {
title(R.string.dialog_send_seed_title)
text(R.string.dialog_send_seed_explanation)
button(R.string.dialog_send_seed_send_button_title) { send() }
cancelButton()
}
private fun send() {
proceed?.invoke()
dismiss()
}
}

View File

@@ -1,16 +1,16 @@
package org.thoughtcrime.securesms.conversation.v2.input_bar
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import android.graphics.PointF
import android.net.Uri
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.util.AttributeSet
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.RelativeLayout
import android.widget.TextView
@@ -28,20 +28,36 @@ import org.thoughtcrime.securesms.conversation.v2.messages.QuoteViewDelegate
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.addTextChangedListener
import org.thoughtcrime.securesms.util.contains
import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate,
// Enums to keep track of the state of our voice recording mechanism as the user can
// manipulate the UI faster than we can setup & teardown.
enum class VoiceRecorderState {
Idle,
SettingUpToRecord,
Recording,
ShuttingDownAfterRecord
}
@SuppressLint("ClickableViewAccessibility")
class InputBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RelativeLayout(
context,
attrs,
defStyleAttr
), InputBarEditTextDelegate,
QuoteViewDelegate,
LinkPreviewDraftViewDelegate,
TextView.OnEditorActionListener {
private lateinit var binding: ViewInputBarBinding
private val screenWidth = Resources.getSystem().displayMetrics.widthPixels
private val vMargin by lazy { toDp(4, resources) }
private val minHeight by lazy { toPx(56, resources) }
private var binding: ViewInputBarBinding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
private var linkPreviewDraftView: LinkPreviewDraftView? = null
private var quoteView: QuoteView? = null
var delegate: InputBarDelegate? = null
var additionalContentHeight = 0
var quote: MessageRecord? = null
var linkPreview: LinkPreview? = null
var showInput: Boolean = true
@@ -54,34 +70,75 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
}
var text: String
get() { return binding.inputBarEditText.text?.toString() ?: "" }
get() = binding.inputBarEditText.text?.toString() ?: ""
set(value) { binding.inputBarEditText.setText(value) }
val attachmentButtonsContainerHeight: Int
get() = binding.attachmentsButtonContainer.height
// Keep track of when the user pressed the record voice message button, the duration that
// they held record, and the current audio recording mechanism state.
private var voiceMessageStartMS = 0L
var voiceMessageDurationMS = 0L
var voiceRecorderState = VoiceRecorderState.Idle
private val attachmentsButton by lazy { InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)} }
private val microphoneButton by lazy { InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)} }
private val sendButton by lazy { InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)} }
private val attachmentsButton = InputBarButton(context, R.drawable.ic_plus_24).apply { contentDescription = context.getString(R.string.AccessibilityId_attachments_button)}
val microphoneButton = InputBarButton(context, R.drawable.ic_microphone).apply { contentDescription = context.getString(R.string.AccessibilityId_microphone_button)}
private val sendButton = InputBarButton(context, R.drawable.ic_arrow_up, true).apply { contentDescription = context.getString(R.string.AccessibilityId_send_message_button)}
// region Lifecycle
constructor(context: Context) : super(context) { initialize() }
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) { initialize() }
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) { initialize() }
private fun initialize() {
binding = ViewInputBarBinding.inflate(LayoutInflater.from(context), this, true)
init {
// Attachments button
binding.attachmentsButtonContainer.addView(attachmentsButton)
attachmentsButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
attachmentsButton.onPress = { toggleAttachmentOptions() }
// Microphone button
binding.microphoneOrSendButtonContainer.addView(microphoneButton)
microphoneButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
microphoneButton.onLongPress = { startRecordingVoiceMessage() }
microphoneButton.onMove = { delegate?.onMicrophoneButtonMove(it) }
microphoneButton.onCancel = { delegate?.onMicrophoneButtonCancel(it) }
microphoneButton.onUp = { delegate?.onMicrophoneButtonUp(it) }
// Use a separate 'raw' OnTouchListener to record the microphone button down/up timestamps because
// they don't get delayed by any multi-threading or delegates which throw off the timestamp accuracy.
// For example: If we bind something to `microphoneButton.onPress` and also log something in
// `microphoneButton.onUp` and tap the button then the logged output order is onUp and THEN onPress!
microphoneButton.setOnTouchListener(object : OnTouchListener {
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (!microphoneButton.snIsEnabled) return true
// We only handle single finger touch events so just consume the event and bail if there are more
if (event.pointerCount > 1) return true
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// Only start spinning up the voice recorder if we're not already recording, setting up, or tearing down
if (voiceRecorderState == VoiceRecorderState.Idle) {
// Take note of when we start recording so we can figure out how long the record button was held for
voiceMessageStartMS = System.currentTimeMillis()
// We are now setting up to record, and when we actually start recording then
// AudioRecorder.startRecording will move us into the Recording state.
voiceRecorderState = VoiceRecorderState.SettingUpToRecord
startRecordingVoiceMessage()
}
}
MotionEvent.ACTION_UP -> {
// Work out how long the record audio button was held for
voiceMessageDurationMS = System.currentTimeMillis() - voiceMessageStartMS;
// Regardless of our current recording state we'll always call the onMicrophoneButtonUp method
// and let the logic in that take the appropriate action as we cannot guarantee that letting
// go of the record button should always stop recording audio because the user may have moved
// the button into the 'locked' state so they don't have to keep it held down to record a voice
// message.
// Also: We need to tear down the voice recorder if it has been recording and is now stopping.
delegate?.onMicrophoneButtonUp(event)
}
}
// Return false to propagate the event rather than consuming it
return false
}
})
// Send button
binding.microphoneOrSendButtonContainer.addView(sendButton)
sendButton.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
@@ -91,16 +148,16 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
delegate?.sendMessage()
}
}
// Edit text
binding.inputBarEditText.setOnEditorActionListener(this)
if (TextSecurePreferences.isEnterSendsEnabled(context)) {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_SEND
binding.inputBarEditText.inputType =
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
binding.inputBarEditText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
} else {
binding.inputBarEditText.imeOptions = EditorInfo.IME_ACTION_NONE
binding.inputBarEditText.inputType =
binding.inputBarEditText.inputType or
binding.inputBarEditText.inputType
InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
}
val incognitoFlag = if (TextSecurePreferences.isIncognitoKeyboardEnabled(context)) 16777216 else 0
@@ -117,29 +174,19 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
return false
}
// endregion
// region Updating
override fun inputBarEditTextContentChanged(text: CharSequence) {
microphoneButton.isVisible = text.trim().isEmpty()
sendButton.isVisible = microphoneButton.isGone
delegate?.inputBarEditTextContentChanged(text)
}
override fun inputBarEditTextHeightChanged(newValue: Int) {
}
override fun inputBarEditTextHeightChanged(newValue: Int) { }
override fun commitInputContent(contentUri: Uri) {
delegate?.commitInputContent(contentUri)
}
override fun commitInputContent(contentUri: Uri) { delegate?.commitInputContent(contentUri) }
private fun toggleAttachmentOptions() {
delegate?.toggleAttachmentOptions()
}
private fun toggleAttachmentOptions() { delegate?.toggleAttachmentOptions() }
private fun startRecordingVoiceMessage() {
delegate?.startRecordingVoiceMessage()
}
private fun startRecordingVoiceMessage() { delegate?.startRecordingVoiceMessage() }
fun draftQuote(thread: Recipient, message: MessageRecord, glide: GlideRequests) {
quoteView?.let(binding.inputBarAdditionalContentContainer::removeView)
@@ -221,18 +268,16 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
setOf(attachmentsButton, microphoneButton).forEach { it.snIsEnabled = showMediaControls }
}
fun addTextChangedListener(textWatcher: TextWatcher) {
binding.inputBarEditText.addTextChangedListener(textWatcher)
fun addTextChangedListener(listener: (String) -> Unit) {
binding.inputBarEditText.addTextChangedListener(listener)
}
fun setInputBarEditableFactory(factory: Editable.Factory) {
binding.inputBarEditText.setEditableFactory(factory)
}
// endregion
}
interface InputBarDelegate {
fun inputBarEditTextContentChanged(newContent: CharSequence)
fun toggleAttachmentOptions()
fun showVoiceMessageUI()
@@ -242,4 +287,4 @@ interface InputBarDelegate {
fun onMicrophoneButtonUp(event: MotionEvent)
fun sendMessage()
fun commitInputContent(contentUri: Uri)
}
}

View File

@@ -25,6 +25,14 @@ import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.toPx
import java.util.Date
// Constants for animation durations in milliseconds
object VoiceRecorderConstants {
const val ANIMATE_LOCK_DURATION_MS = 250L
const val DOT_ANIMATION_DURATION_MS = 500L
const val DOT_PULSE_ANIMATION_DURATION_MS = 1000L
const val SHOW_HIDE_VOICE_UI_DURATION_MS = 250L
}
class InputBarRecordingView : RelativeLayout {
private lateinit var binding: ViewInputBarRecordingBinding
private var startTimestamp = 0L
@@ -79,7 +87,7 @@ class InputBarRecordingView : RelativeLayout {
fun hide() {
alpha = 1.0f
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
animation.duration = 250L
animation.duration = VoiceRecorderConstants.SHOW_HIDE_VOICE_UI_DURATION_MS
animation.addUpdateListener { animator ->
alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f) {
@@ -113,7 +121,7 @@ class InputBarRecordingView : RelativeLayout {
private fun animateDotView() {
val animation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
dotViewAnimation = animation
animation.duration = 500L
animation.duration = VoiceRecorderConstants.DOT_ANIMATION_DURATION_MS
animation.addUpdateListener { animator ->
binding.dotView.alpha = animator.animatedValue as Float
}
@@ -128,7 +136,7 @@ class InputBarRecordingView : RelativeLayout {
binding.pulseView.animateSizeChange(collapsedSize, expandedSize, 1000)
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.5, 0.0f)
pulseAnimation = animation
animation.duration = 1000L
animation.duration = VoiceRecorderConstants.DOT_PULSE_ANIMATION_DURATION_MS
animation.addUpdateListener { animator ->
binding.pulseView.alpha = animator.animatedValue as Float
if (animator.animatedFraction == 1.0f && isVisible) { pulse() }
@@ -143,7 +151,7 @@ class InputBarRecordingView : RelativeLayout {
layoutParams.bottomMargin = startMarginBottom
binding.lockView.layoutParams = layoutParams
val animation = ValueAnimator.ofObject(IntEvaluator(), startMarginBottom, endMarginBottom)
animation.duration = 250L
animation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
animation.addUpdateListener { animator ->
layoutParams.bottomMargin = animator.animatedValue as Int
binding.lockView.layoutParams = layoutParams
@@ -153,21 +161,25 @@ class InputBarRecordingView : RelativeLayout {
fun lock() {
val fadeOutAnimation = ValueAnimator.ofObject(FloatEvaluator(), 1.0f, 0.0f)
fadeOutAnimation.duration = 250L
fadeOutAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
fadeOutAnimation.addUpdateListener { animator ->
binding.inputBarMiddleContentContainer.alpha = animator.animatedValue as Float
binding.lockView.alpha = animator.animatedValue as Float
}
fadeOutAnimation.start()
val fadeInAnimation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
fadeInAnimation.duration = 250L
fadeInAnimation.duration = VoiceRecorderConstants.ANIMATE_LOCK_DURATION_MS
fadeInAnimation.addUpdateListener { animator ->
binding.inputBarCancelButton.alpha = animator.animatedValue as Float
}
fadeInAnimation.start()
binding.recordButtonOverlayImageView.setImageDrawable(ResourcesCompat.getDrawable(resources, R.drawable.ic_arrow_up, context.theme))
binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() }
binding.inputBarCancelButton.setOnClickListener { delegate?.cancelVoiceMessage() }
// When the user has locked the voice recorder button on then THIS is where the next click
// is registered to actually send the voice message - it does NOT hit the microphone button
// onTouch listener again.
binding.recordButtonOverlay.setOnClickListener { delegate?.sendVoiceMessage() }
}
}

View File

@@ -117,9 +117,9 @@ class MentionViewModel(
contactDatabase.getContacts(memberIDs).map { contact ->
Member(
publicKey = contact.sessionID,
publicKey = contact.accountID,
name = contact.displayName(contactContext).orEmpty(),
isModerator = contact.sessionID in moderatorIDs,
isModerator = contact.accountID in moderatorIDs,
)
}
}

View File

@@ -6,7 +6,7 @@ import android.view.Menu
import android.view.MenuItem
import network.loki.messenger.R
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.IdPrefix
@@ -39,7 +39,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
val userPublicKey = TextSecurePreferences.getLocalNumber(context)!!
val edKeyPair = MessagingModuleConfiguration.shared.getUserED25519KeyPair()!!
val blindedPublicKey = openGroup?.publicKey?.let { SodiumUtilities.blindedKeyPair(it, edKeyPair)?.publicKey?.asBytes }
?.let { SessionId(IdPrefix.BLINDED, it) }?.hexString
?.let { AccountId(IdPrefix.BLINDED, it) }?.hexString
fun userCanDeleteSelectedItems(): Boolean {
val allSentByCurrentUser = selectedItems.all { it.isOutgoing }
val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing }
@@ -63,7 +63,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
menu.findItem(R.id.menu_context_ban_and_delete_all).isVisible = userCanBanSelectedUsers()
// Copy message text
menu.findItem(R.id.menu_context_copy).isVisible = !containsControlMessage && hasText
// Copy Session ID
// Copy Account ID
menu.findItem(R.id.menu_context_copy_public_key).isVisible =
(thread.isGroupRecipient && !thread.isCommunityRecipient && selectedItems.size == 1 && firstMessage.individualRecipient.address.toString() != userPublicKey)
// Message detail
@@ -91,7 +91,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p
R.id.menu_context_ban_user -> delegate?.banUser(selectedItems)
R.id.menu_context_ban_and_delete_all -> delegate?.banAndDeleteAll(selectedItems)
R.id.menu_context_copy -> delegate?.copyMessages(selectedItems)
R.id.menu_context_copy_public_key -> delegate?.copySessionID(selectedItems)
R.id.menu_context_copy_public_key -> delegate?.copyAccountID(selectedItems)
R.id.menu_context_resync -> delegate?.resyncMessage(selectedItems)
R.id.menu_context_resend -> delegate?.resendMessage(selectedItems)
R.id.menu_message_details -> delegate?.showMessageDetail(selectedItems)
@@ -115,7 +115,7 @@ interface ConversationActionModeCallbackDelegate {
fun banUser(messages: Set<MessageRecord>)
fun banAndDeleteAll(messages: Set<MessageRecord>)
fun copyMessages(messages: Set<MessageRecord>)
fun copySessionID(messages: Set<MessageRecord>)
fun copyAccountID(messages: Set<MessageRecord>)
fun resyncMessage(messages: Set<MessageRecord>)
fun resendMessage(messages: Set<MessageRecord>)
fun showMessageDetail(messages: Set<MessageRecord>)

View File

@@ -57,9 +57,9 @@ object ConversationMenuHelper {
if (!isOpenGroup && (thread.hasApprovedMe() || thread.isClosedGroupRecipient || thread.isLocalNumber)) {
inflater.inflate(R.menu.menu_conversation_expiration, menu)
}
// One-on-one chat menu allows copying the session id
// One-on-one chat menu allows copying the account id
if (thread.isContactRecipient) {
inflater.inflate(R.menu.menu_conversation_copy_session_id, menu)
inflater.inflate(R.menu.menu_conversation_copy_account_id, menu)
}
// One-on-one chat menu (options that should only be present for one-on-one chats)
if (thread.isContactRecipient) {
@@ -135,7 +135,7 @@ object ConversationMenuHelper {
R.id.menu_unblock -> { unblock(context, thread) }
R.id.menu_block -> { block(context, thread, deleteThread = false) }
R.id.menu_block_delete -> { blockAndDelete(context, thread) }
R.id.menu_copy_session_id -> { copySessionID(context, thread) }
R.id.menu_copy_account_id -> { copyAccountID(context, thread) }
R.id.menu_copy_open_group_url -> { copyOpenGroupUrl(context, thread) }
R.id.menu_edit_group -> { editClosedGroup(context, thread) }
R.id.menu_leave_group -> { leaveClosedGroup(context, thread) }
@@ -246,10 +246,10 @@ object ConversationMenuHelper {
listener.block(deleteThread = true)
}
private fun copySessionID(context: Context, thread: Recipient) {
private fun copyAccountID(context: Context, thread: Recipient) {
if (!thread.isContactRecipient) { return }
val listener = context as? ConversationMenuListener ?: return
listener.copySessionID(thread.address.toString())
listener.copyAccountID(thread.address.toString())
}
private fun copyOpenGroupUrl(context: Context, thread: Recipient) {
@@ -271,8 +271,8 @@ object ConversationMenuHelper {
val group = DatabaseComponent.get(context).groupDatabase().getGroup(thread.address.toGroupString()).orNull()
val admins = group.admins
val sessionID = TextSecurePreferences.getLocalNumber(context)
val isCurrentUserAdmin = admins.any { it.toString() == sessionID }
val accountID = TextSecurePreferences.getLocalNumber(context)
val isCurrentUserAdmin = admins.any { it.toString() == accountID }
val message = if (isCurrentUserAdmin) {
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone."
} else {
@@ -325,7 +325,7 @@ object ConversationMenuHelper {
interface ConversationMenuListener {
fun block(deleteThread: Boolean = false)
fun unblock()
fun copySessionID(sessionId: String)
fun copyAccountID(accountId: String)
fun copyOpenGroupUrl(thread: Recipient)
fun showDisappearingMessages(thread: Recipient)
}

View File

@@ -70,7 +70,7 @@ class QuoteView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
isOutgoingMessage: Boolean, isOpenGroupInvitation: Boolean, threadID: Long,
isOriginalMissing: Boolean, glide: GlideRequests) {
// Author
val author = contactDb.getContactWithSessionID(authorPublicKey)
val author = contactDb.getContactWithAccountID(authorPublicKey)
val localNumber = TextSecurePreferences.getLocalNumber(context)
val quoteIsLocalUser = localNumber != null && authorPublicKey == localNumber

View File

@@ -22,6 +22,7 @@ import androidx.core.view.isVisible
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewVisibleMessageContentBinding
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
@@ -289,7 +290,7 @@ class VisibleMessageContentView : ConstraintLayout {
// replace URLSpans with ModalURLSpans
body.getSpans<URLSpan>(0, body.length).toList().forEach { urlSpan ->
val updatedUrl = urlSpan.url.let { HttpUrl.parse(it).toString() }
val updatedUrl = urlSpan.url.let { it.toHttpUrlOrNull().toString() }
val replacementSpan = ModalURLSpan(updatedUrl) { url ->
val activity = context as AppCompatActivity
ModalUrlBottomSheet(url).show(activity.supportFragmentManager, "Open URL Dialog")

View File

@@ -36,6 +36,7 @@ import org.session.libsession.messaging.contacts.Contact.ContactContext
import org.session.libsession.messaging.open_groups.OpenGroupApi
import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ThemeUtil.getThemedColor
import org.session.libsession.utilities.ViewUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.modifyLayoutParams
@@ -143,7 +144,7 @@ class VisibleMessageView : FrameLayout {
glide: GlideRequests = GlideApp.with(this),
searchQuery: String? = null,
contact: Contact? = null,
senderSessionID: String,
senderAccountID: String,
lastSeen: Long,
delegate: VisibleMessageViewDelegate? = null,
onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit,
@@ -178,30 +179,30 @@ class VisibleMessageView : FrameLayout {
if (isGroupThread && !message.isOutgoing) {
if (isEndOfMessageCluster) {
binding.profilePictureView.publicKey = senderSessionID
binding.profilePictureView.publicKey = senderAccountID
binding.profilePictureView.update(message.individualRecipient)
binding.profilePictureView.setOnClickListener {
if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID)
if (IdPrefix.fromValue(senderSessionID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
if (IdPrefix.fromValue(senderAccountID) == IdPrefix.BLINDED && openGroup?.canWrite == true) {
// TODO: support v2 soon
val intent = Intent(context, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.FROM_GROUP_THREAD_ID, threadID)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderSessionID))
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(senderAccountID))
context.startActivity(intent)
}
} else {
maybeShowUserDetails(senderSessionID, threadID)
maybeShowUserDetails(senderAccountID, threadID)
}
}
if (thread.isCommunityRecipient) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadID) ?: return
var standardPublicKey = ""
var blindedPublicKey: String? = null
if (IdPrefix.fromValue(senderSessionID)?.isBlinded() == true) {
blindedPublicKey = senderSessionID
if (IdPrefix.fromValue(senderAccountID)?.isBlinded() == true) {
blindedPublicKey = senderAccountID
} else {
standardPublicKey = senderSessionID
standardPublicKey = senderAccountID
}
val isModerator = OpenGroupManager.isUserModerator(context, openGroup.groupId, standardPublicKey, blindedPublicKey)
binding.moderatorIconImageView.isVisible = !message.isOutgoing && isModerator
@@ -211,7 +212,7 @@ class VisibleMessageView : FrameLayout {
binding.senderNameTextView.isVisible = !message.isOutgoing && (isStartOfMessageCluster && (isGroupThread || snIsSelected))
val contactContext =
if (thread.isCommunityRecipient) ContactContext.OPEN_GROUP else ContactContext.REGULAR
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderSessionID
binding.senderNameTextView.text = contact?.displayName(contactContext) ?: senderAccountID
// Unread marker
val shouldShowUnreadMarker = lastSeen != -1L && message.timestamp > lastSeen && (previous == null || previous.timestamp <= lastSeen) && !message.isOutgoing
@@ -382,7 +383,7 @@ class VisibleMessageView : FrameLayout {
private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when {
message.isFailed ->
MessageStatusInfo(R.drawable.ic_delivery_status_failed,
resources.getColor(R.color.destructive, context.theme),
getThemedColor(context, R.attr.danger),
R.string.delivery_status_failed
)
message.isSyncFailed ->

View File

@@ -1,15 +1,12 @@
package org.thoughtcrime.securesms.conversation.v2.utilities
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.Spannable
import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Range
import androidx.core.content.res.ResourcesCompat
import network.loki.messenger.R
import nl.komponents.kovenant.combine.Tuple2
import org.session.libsession.messaging.contacts.Contact
@@ -22,7 +19,6 @@ import org.session.libsession.utilities.truncateIdForDisplay
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.util.RoundedBackgroundSpan
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.toPx
import java.util.regex.Pattern
object MentionUtilities {
@@ -66,7 +62,7 @@ object MentionUtilities {
val userDisplayName: String? = if (isYou) {
context.getString(R.string.MessageRecord_you)
} else {
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(publicKey)
val contact = DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(publicKey)
@Suppress("NAME_SHADOWING") val context = if (openGroup != null) Contact.ContactContext.OPEN_GROUP else Contact.ContactContext.REGULAR
contact?.displayName(context) ?: truncateIdForDisplay(publicKey)
}
@@ -161,7 +157,7 @@ object MentionUtilities {
}
private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean {
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.accountId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false
return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey
}
}

View File

@@ -31,7 +31,7 @@ class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) :
private fun readBlindedIdMapping(cursor: Cursor): BlindedIdMapping {
return BlindedIdMapping(
blindedId = cursor.getString(cursor.getColumnIndexOrThrow(BLINDED_PK)),
sessionId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(SESSION_PK)),
accountId = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(SESSION_PK)),
serverUrl = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_URL)),
serverId = cursor.getString(cursor.getColumnIndexOrThrow(SERVER_PK)),
)
@@ -58,7 +58,7 @@ class BlindedIdMappingDatabase(context: Context, helper: SQLCipherOpenHelper) :
try {
val values = ContentValues().apply {
put(BLINDED_PK, blindedIdMapping.blindedId)
put(SERVER_PK, blindedIdMapping.sessionId)
put(SERVER_PK, blindedIdMapping.accountId)
put(SERVER_URL, blindedIdMapping.serverUrl)
put(SERVER_PK, blindedIdMapping.serverId)
}

View File

@@ -1242,73 +1242,50 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa
}
private fun getNotificationMmsMessageRecord(cursor: Cursor): NotificationMmsMessageRecord {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID))
val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val dateReceived = cursor.getLong(
cursor.getColumnIndexOrThrow(
NORMALIZED_DATE_RECEIVED
)
)
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
val mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX))
val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID))
val recipient = getRecipientFor(address)
val contentLocation = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_LOCATION))
val transactionId = cursor.getString(cursor.getColumnIndexOrThrow(TRANSACTION_ID))
val messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_SIZE))
val expiry = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRY))
val status = cursor.getInt(cursor.getColumnIndexOrThrow(STATUS))
val deliveryReceiptCount = cursor.getInt(
cursor.getColumnIndexOrThrow(
DELIVERY_RECEIPT_COUNT
)
)
val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0
val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1)
val contentLocationBytes: ByteArray? = contentLocation?.takeUnless { it.isEmpty() }?.let(::toIsoBytes)
val transactionIdBytes: ByteArray? = transactionId?.takeUnless { it.isEmpty() }?.let(::toIsoBytes)
val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize))
// Note: Additional details such as ADDRESS_DEVICE_ID, CONTENT_LOCATION, and TRANSACTION_ID are available if required.
val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID))
val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_RECEIVED))
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
val mailbox = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX))
val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
val recipient = getRecipientFor(address)
val messageSize = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_SIZE))
val expiry = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRY))
val status = cursor.getInt(cursor.getColumnIndexOrThrow(STATUS))
val deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(DELIVERY_RECEIPT_COUNT))
val readReceiptCount = if (isReadReceiptsEnabled(context)) cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT)) else 0
val hasMention = (cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1)
val slideDeck = SlideDeck(context, MmsNotificationAttachment(status, messageSize))
return NotificationMmsMessageRecord(
id, recipient, recipient,
dateSent, dateReceived, deliveryReceiptCount, threadId,
contentLocationBytes, messageSize, expiry, status,
transactionIdBytes, mailbox, slideDeck,
messageSize, expiry, status, mailbox, slideDeck,
readReceiptCount, hasMention
)
}
private fun getMediaMmsMessageRecord(cursor: Cursor, getQuote: Boolean): MediaMmsMessageRecord {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID))
val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val dateReceived = cursor.getLong(
cursor.getColumnIndexOrThrow(
NORMALIZED_DATE_RECEIVED
)
)
val box = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX))
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID))
val deliveryReceiptCount = cursor.getInt(
cursor.getColumnIndexOrThrow(
DELIVERY_RECEIPT_COUNT
)
)
var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT))
val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY))
val partCount = cursor.getInt(cursor.getColumnIndexOrThrow(PART_COUNT))
val mismatchDocument = cursor.getString(
cursor.getColumnIndexOrThrow(
MISMATCHED_IDENTITIES
)
)
val networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(NETWORK_FAILURE))
val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN))
val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED))
val unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1
val hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1
val id = cursor.getLong(cursor.getColumnIndexOrThrow(ID))
val dateSent = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_SENT))
val dateReceived = cursor.getLong(cursor.getColumnIndexOrThrow(NORMALIZED_DATE_RECEIVED))
val box = cursor.getLong(cursor.getColumnIndexOrThrow(MESSAGE_BOX))
val threadId = cursor.getLong(cursor.getColumnIndexOrThrow(THREAD_ID))
val address = cursor.getString(cursor.getColumnIndexOrThrow(ADDRESS))
val addressDeviceId = cursor.getInt(cursor.getColumnIndexOrThrow(ADDRESS_DEVICE_ID))
val deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(DELIVERY_RECEIPT_COUNT))
var readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(READ_RECEIPT_COUNT))
val body = cursor.getString(cursor.getColumnIndexOrThrow(BODY))
val partCount = cursor.getInt(cursor.getColumnIndexOrThrow(PART_COUNT))
val mismatchDocument = cursor.getString(cursor.getColumnIndexOrThrow(MISMATCHED_IDENTITIES))
val networkDocument = cursor.getString(cursor.getColumnIndexOrThrow(NETWORK_FAILURE))
val subscriptionId = cursor.getInt(cursor.getColumnIndexOrThrow(SUBSCRIPTION_ID))
val expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRES_IN))
val expireStarted = cursor.getLong(cursor.getColumnIndexOrThrow(EXPIRE_STARTED))
val unidentified = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED)) == 1
val hasMention = cursor.getInt(cursor.getColumnIndexOrThrow(HAS_MENTION)) == 1
if (!isReadReceiptsEnabled(context)) {
readReceiptCount = 0
}

View File

@@ -55,7 +55,7 @@ public class RecipientDatabase extends Database {
private static final String SYSTEM_PHONE_LABEL = "system_phone_label";
private static final String SYSTEM_CONTACT_URI = "system_contact_uri";
private static final String SIGNAL_PROFILE_NAME = "signal_profile_name";
private static final String SIGNAL_PROFILE_AVATAR = "signal_profile_avatar";
private static final String SESSION_PROFILE_AVATAR = "signal_profile_avatar";
private static final String PROFILE_SHARING = "profile_sharing_approval";
private static final String CALL_RINGTONE = "call_ringtone";
private static final String CALL_VIBRATE = "call_vibrate";
@@ -69,7 +69,7 @@ public class RecipientDatabase extends Database {
private static final String[] RECIPIENT_PROJECTION = new String[] {
BLOCK, APPROVED, APPROVED_ME, NOTIFICATION, CALL_RINGTONE, VIBRATE, CALL_VIBRATE, MUTE_UNTIL, COLOR, SEEN_INVITE_REMINDER, DEFAULT_SUBSCRIPTION_ID, EXPIRE_MESSAGES, REGISTERED,
PROFILE_KEY, SYSTEM_DISPLAY_NAME, SYSTEM_PHOTO_URI, SYSTEM_PHONE_LABEL, SYSTEM_CONTACT_URI,
SIGNAL_PROFILE_NAME, SIGNAL_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
SIGNAL_PROFILE_NAME, SESSION_PROFILE_AVATAR, PROFILE_SHARING, NOTIFICATION_CHANNEL,
UNIDENTIFIED_ACCESS_MODE,
FORCE_SMS_SELECTION, NOTIFY_TYPE, DISAPPEARING_STATE, WRAPPER_HASH, BLOCKS_COMMUNITY_MESSAGE_REQUESTS
};
@@ -97,7 +97,7 @@ public class RecipientDatabase extends Database {
SYSTEM_CONTACT_URI + " TEXT DEFAULT NULL, " +
PROFILE_KEY + " TEXT DEFAULT NULL, " +
SIGNAL_PROFILE_NAME + " TEXT DEFAULT NULL, " +
SIGNAL_PROFILE_AVATAR + " TEXT DEFAULT NULL, " +
SESSION_PROFILE_AVATAR + " TEXT DEFAULT NULL, " +
PROFILE_SHARING + " INTEGER DEFAULT 0, " +
CALL_RINGTONE + " TEXT DEFAULT NULL, " +
CALL_VIBRATE + " INTEGER DEFAULT " + Recipient.VibrateState.DEFAULT.getId() + ", " +
@@ -204,7 +204,7 @@ public class RecipientDatabase extends Database {
String systemPhoneLabel = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_PHONE_LABEL));
String systemContactUri = cursor.getString(cursor.getColumnIndexOrThrow(SYSTEM_CONTACT_URI));
String signalProfileName = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_NAME));
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SIGNAL_PROFILE_AVATAR));
String signalProfileAvatar = cursor.getString(cursor.getColumnIndexOrThrow(SESSION_PROFILE_AVATAR));
boolean profileSharing = cursor.getInt(cursor.getColumnIndexOrThrow(PROFILE_SHARING)) == 1;
String notificationChannel = cursor.getString(cursor.getColumnIndexOrThrow(NOTIFICATION_CHANNEL));
int unidentifiedAccessMode = cursor.getInt(cursor.getColumnIndexOrThrow(UNIDENTIFIED_ACCESS_MODE));
@@ -361,7 +361,7 @@ public class RecipientDatabase extends Database {
public void setProfileAvatar(@NonNull Recipient recipient, @Nullable String profileAvatar) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(SIGNAL_PROFILE_AVATAR, profileAvatar);
contentValues.put(SESSION_PROFILE_AVATAR, profileAvatar);
updateOrInsert(recipient.getAddress(), contentValues);
recipient.resolve().setProfileAvatar(profileAvatar);
notifyRecipientListeners();

View File

@@ -6,7 +6,7 @@ import android.database.Cursor
import androidx.core.database.getStringOrNull
import org.json.JSONArray
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsignal.utilities.Base64
import org.session.libsignal.utilities.IdPrefix
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper
@@ -15,7 +15,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
companion object {
private const val sessionContactTable = "session_contact_database"
const val sessionID = "session_id"
const val accountID = "session_id"
const val name = "name"
const val nickname = "nickname"
const val profilePictureURL = "profile_picture_url"
@@ -25,7 +25,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
const val isTrusted = "is_trusted"
@JvmStatic val createSessionContactTableCommand =
"CREATE TABLE $sessionContactTable " +
"($sessionID STRING PRIMARY KEY, " +
"($accountID STRING PRIMARY KEY, " +
"$name TEXT DEFAULT NULL, " +
"$nickname TEXT DEFAULT NULL, " +
"$profilePictureURL TEXT DEFAULT NULL, " +
@@ -35,19 +35,19 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
"$isTrusted INTEGER DEFAULT 0);"
}
fun getContactWithSessionID(sessionID: String): Contact? {
fun getContactWithAccountID(accountID: String): Contact? {
val database = databaseHelper.readableDatabase
return database.get(sessionContactTable, "${Companion.sessionID} = ?", arrayOf( sessionID )) { cursor ->
return database.get(sessionContactTable, "${Companion.accountID} = ?", arrayOf( accountID )) { cursor ->
contactFromCursor(cursor)
}
}
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
fun getContacts(accountIDs: Collection<String>): List<Contact> {
val database = databaseHelper.readableDatabase
return database.getAll(
sessionContactTable,
"$sessionID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(sessionIDs).toString())
"$accountID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(accountIDs).toString())
) { cursor -> contactFromCursor(cursor) }
}
@@ -56,8 +56,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
return database.getAll(sessionContactTable, null, null) { cursor ->
contactFromCursor(cursor)
}.filter { contact ->
val sessionId = SessionId(contact.sessionID)
sessionId.prefix == IdPrefix.STANDARD
contact.accountID.let(::AccountId).prefix == IdPrefix.STANDARD
}.toSet()
}
@@ -65,7 +64,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
val database = databaseHelper.writableDatabase
val contentValues = ContentValues(1)
contentValues.put(Companion.isTrusted, if (isTrusted) 1 else 0)
database.update(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID ))
database.update(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
if (threadID >= 0) {
notifyConversationListeners(threadID)
}
@@ -75,7 +74,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
fun setContact(contact: Contact) {
val database = databaseHelper.writableDatabase
val contentValues = ContentValues(8)
contentValues.put(sessionID, contact.sessionID)
contentValues.put(accountID, contact.accountID)
contentValues.put(name, contact.name)
contentValues.put(nickname, contact.nickname)
contentValues.put(profilePictureURL, contact.profilePictureURL)
@@ -85,13 +84,13 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
}
contentValues.put(threadID, contact.threadID)
contentValues.put(isTrusted, if (contact.isTrusted) 1 else 0)
database.insertOrUpdate(sessionContactTable, contentValues, "$sessionID = ?", arrayOf( contact.sessionID ))
database.insertOrUpdate(sessionContactTable, contentValues, "$accountID = ?", arrayOf( contact.accountID ))
notifyConversationListListeners()
}
fun contactFromCursor(cursor: Cursor): Contact {
val sessionID = cursor.getString(cursor.getColumnIndexOrThrow(sessionID))
val contact = Contact(sessionID)
val accountID = cursor.getString(cursor.getColumnIndexOrThrow(accountID))
val contact = Contact(accountID)
contact.name = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(name))
contact.nickname = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(nickname))
contact.profilePictureURL = cursor.getStringOrNull(cursor.getColumnIndexOrThrow(profilePictureURL))

View File

@@ -2,14 +2,17 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
import java.security.MessageDigest
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import network.loki.messenger.libsession_util.util.BaseCommunityInfo
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo
@@ -55,7 +58,7 @@ import org.session.libsession.messaging.sending_receiving.link_preview.LinkPrevi
import org.session.libsession.messaging.sending_receiving.notifications.PushRegistryV1
import org.session.libsession.messaging.sending_receiving.pollers.ClosedGroupPollerV2
import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel
import org.session.libsession.messaging.utilities.SessionId
import org.session.libsession.messaging.utilities.AccountId
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.messaging.utilities.UpdateMessageData
import org.session.libsession.snode.OnionRequestAPI
@@ -66,6 +69,7 @@ import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.SSKEnvironment
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.recipients.Recipient.DisappearingState
@@ -91,8 +95,6 @@ import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.SessionMetaProtocol
import java.security.MessageDigest
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
private const val TAG = "Storage"
@@ -110,12 +112,12 @@ open class Storage(
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
val closedGroup = getGroup(address.toGroupString())
if (closedGroup != null && closedGroup.isActive) {
val legacyGroup = groups.getOrConstructLegacyGroupInfo(sessionId)
val legacyGroup = groups.getOrConstructLegacyGroupInfo(accountId)
groups.set(legacyGroup)
val newVolatileParams = volatile.getOrConstructLegacyGroup(sessionId).copy(
val newVolatileParams = volatile.getOrConstructLegacyGroup(accountId).copy(
lastRead = SnodeAPI.nowWithOffset,
)
volatile.set(newVolatileParams)
@@ -126,16 +128,16 @@ open class Storage(
}
} else if (address.isContact) {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return
// don't update our own address into the contacts DB
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(address.serialize()) {
priority = ConfigBase.PRIORITY_VISIBLE
priority = PRIORITY_VISIBLE
}
} else {
val userProfile = configFactory.user ?: return
userProfile.setNtsPriority(ConfigBase.PRIORITY_VISIBLE)
userProfile.setNtsPriority(PRIORITY_VISIBLE)
DatabaseComponent.get(context).threadDatabase().setHasSent(threadId, true)
}
val newVolatileParams = volatile.getOrConstructOneToOne(address.serialize())
@@ -148,16 +150,16 @@ open class Storage(
if (address.isGroup) {
val groups = configFactory.userGroups ?: return
if (address.isClosedGroup) {
val sessionId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(sessionId)
groups.eraseLegacyGroup(sessionId)
val accountId = GroupUtil.doubleDecodeGroupId(address.serialize())
volatile.eraseLegacyClosedGroup(accountId)
groups.eraseLegacyGroup(accountId)
} else if (address.isCommunity) {
// these should be removed in the group leave / handling new configs
Log.w("Loki", "Thread delete called for open group address, expecting to be handled elsewhere")
}
} else {
// non-standard contact prefixes: 15, 00 etc shouldn't be stored in config
if (SessionId(address.serialize()).prefix != IdPrefix.STANDARD) return
if (AccountId(address.serialize()).prefix != IdPrefix.STANDARD) return
volatile.eraseOneToOne(address.serialize())
if (getUserPublicKey() != address.serialize()) {
val contacts = configFactory.contacts ?: return
@@ -264,10 +266,8 @@ open class Storage(
}
// otherwise recipient is one to one
recipient.isContactRecipient -> {
// don't process non-standard session IDs though
val sessionId = SessionId(recipient.address.serialize())
if (sessionId.prefix != IdPrefix.STANDARD) return
// don't process non-standard account IDs though
if (AccountId(recipient.address.serialize()).prefix != IdPrefix.STANDARD) return
config.getOrConstructOneToOne(recipient.address.serialize())
}
else -> throw NullPointerException("Weren't expecting to have a convo with address ${recipient.address.serialize()}")
@@ -298,8 +298,8 @@ open class Storage(
var messageID: Long? = null
val senderAddress = fromSerialized(message.sender!!)
val isUserSender = (message.sender!! == getUserPublicKey())
val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let { getOpenGroup(it)?.publicKey }
?.let { SodiumUtilities.sessionId(getUserPublicKey()!!, message.sender!!, it) } ?: false
val isUserBlindedSender = message.threadID?.takeIf { it >= 0 }?.let(::getOpenGroup)?.publicKey
?.let { SodiumUtilities.accountId(getUserPublicKey()!!, message.sender!!, it) } ?: false
val group: Optional<SignalServiceGroup> = when {
openGroupID != null -> Optional.of(SignalServiceGroup(openGroupID.toByteArray(), SignalServiceGroup.GroupType.PUBLIC_CHAT))
groupPublicKey != null -> {
@@ -471,22 +471,26 @@ open class Storage(
val userPublicKey = getUserPublicKey() ?: return
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// update name
// Update profile name
val name = userProfile.getName() ?: return
val userPic = userProfile.getPic()
val profileManager = SSKEnvironment.shared.profileManager
if (name.isNotEmpty()) {
TextSecurePreferences.setProfileName(context, name)
profileManager.setName(context, recipient, name)
name.takeUnless { it.isEmpty() }?.truncate(NAME_PADDED_LENGTH)?.let {
TextSecurePreferences.setProfileName(context, it)
profileManager.setName(context, recipient, it)
if (it != name) userProfile.setName(it)
}
// update pfp
// Update profile picture
if (userPic == UserPic.DEFAULT) {
clearUserPic()
} else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty()
&& TextSecurePreferences.getProfilePictureURL(context) != userPic.url) {
setUserProfilePicture(userPic.url, userPic.key)
}
if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) {
// delete nts thread if needed
val ourThread = getThreadId(recipient) ?: return
@@ -514,12 +518,13 @@ open class Storage(
addLibSessionContacts(extracted, messageTimestamp)
}
override fun clearUserPic() {
val userPublicKey = getUserPublicKey() ?: return
override fun clearUserPic() {
val userPublicKey = getUserPublicKey() ?: return Log.w(TAG, "No user public key when trying to clear user pic")
val recipientDatabase = DatabaseComponent.get(context).recipientDatabase()
// would love to get rid of recipient and context from this
val recipient = Recipient.from(context, fromSerialized(userPublicKey), false)
// clear picture if userPic is null
// Clear details related to the user's profile picture
TextSecurePreferences.setProfileKey(context, null)
ProfileKeyUtil.setEncodedProfileKey(context, null)
recipientDatabase.setProfileAvatar(recipient, null)
@@ -528,14 +533,13 @@ open class Storage(
Recipient.removeCached(fromSerialized(userPublicKey))
configFactory.user?.setPic(UserPic.DEFAULT)
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
}
private fun updateConvoVolatile(convos: ConversationVolatileConfig, messageTimestamp: Long) {
val extracted = convos.all()
for (conversation in extracted) {
val threadId = when (conversation) {
is Conversation.OneToOne -> getThreadIdFor(conversation.sessionId, null, null, createThread = false)
is Conversation.OneToOne -> getThreadIdFor(conversation.accountId, null, null, createThread = false)
is Conversation.LegacyGroup -> getThreadIdFor("", conversation.groupId,null, createThread = false)
is Conversation.Community -> getThreadIdFor("",null, "${conversation.baseCommunityInfo.baseUrl.removeSuffix("/")}.${conversation.baseCommunityInfo.room}", createThread = false)
}
@@ -566,7 +570,7 @@ open class Storage(
val existingJoinUrls = existingCommunities.values.map { it.joinURL }
val existingClosedGroups = getAllGroups(includeInactive = true).filter { it.isClosedGroup }
val lgcIds = lgc.map { it.sessionId }
val lgcIds = lgc.map { it.accountId }
val toDeleteClosedGroups = existingClosedGroups.filter { group ->
GroupUtil.doubleDecodeGroupId(group.encodedId) !in lgcIds
}
@@ -600,8 +604,8 @@ open class Storage(
}
for (group in lgc) {
val groupId = GroupUtil.doubleEncodeGroupID(group.sessionId)
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.sessionId }
val groupId = GroupUtil.doubleEncodeGroupID(group.accountId)
val existingGroup = existingClosedGroups.firstOrNull { GroupUtil.doubleDecodeGroupId(it.encodedId) == group.accountId }
val existingThread = existingGroup?.let { getThreadId(existingGroup.encodedId) }
if (existingGroup != null) {
if (group.priority == PRIORITY_HIDDEN && existingThread != null) {
@@ -620,19 +624,19 @@ open class Storage(
createGroup(groupId, title, admins + members, null, null, admins, formationTimestamp)
setProfileSharing(Address.fromSerialized(groupId), true)
// Add the group to the user's set of public keys to poll for
addClosedGroupPublicKey(group.sessionId)
addClosedGroupPublicKey(group.accountId)
// Store the encryption key pair
val keyPair = ECKeyPair(DjbECPublicKey(group.encPubKey), DjbECPrivateKey(group.encSecKey))
addClosedGroupEncryptionKeyPair(keyPair, group.sessionId, SnodeAPI.nowWithOffset)
addClosedGroupEncryptionKeyPair(keyPair, group.accountId, SnodeAPI.nowWithOffset)
// Notify the PN server
PushRegistryV1.subscribeGroup(group.sessionId, publicKey = localUserPublicKey)
PushRegistryV1.subscribeGroup(group.accountId, publicKey = localUserPublicKey)
// Notify the user
val threadID = getOrCreateThreadIdFor(Address.fromSerialized(groupId))
threadDb.setDate(threadID, formationTimestamp)
insertOutgoingInfoMessage(context, groupId, SignalServiceGroup.Type.CREATION, title, members.map { it.serialize() }, admins.map { it.serialize() }, threadID, formationTimestamp)
// Don't create config group here, it's from a config update
// Start polling
ClosedGroupPollerV2.shared.startPolling(group.sessionId)
ClosedGroupPollerV2.shared.startPolling(group.accountId)
}
getThreadId(Address.fromSerialized(groupId))?.let {
setExpirationConfiguration(
@@ -933,10 +937,10 @@ open class Storage(
groupVolatileConfig.lastRead = formationTimestamp
volatiles.set(groupVolatileConfig)
val groupInfo = GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey,
accountId = groupPublicKey,
name = name,
members = members,
priority = ConfigBase.PRIORITY_VISIBLE,
priority = PRIORITY_VISIBLE,
encPubKey = (encryptionKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = encryptionKeyPair.privateKey.serialize(),
disappearingTimer = expirationTimer.toLong(),
@@ -970,7 +974,7 @@ open class Storage(
members = membersMap,
encPubKey = (latestKeyPair.publicKey as DjbECPublicKey).publicKey, // 'serialize()' inserts an extra byte
encSecKey = latestKeyPair.privateKey.serialize(),
priority = if (isPinned(threadID)) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,
priority = if (isPinned(threadID)) PRIORITY_PINNED else PRIORITY_VISIBLE,
disappearingTimer = getExpirationConfiguration(threadID)?.expiryMode?.expirySeconds ?: 0L,
joinedAt = (existingGroup.formationTimestamp / 1000L)
)
@@ -1175,8 +1179,8 @@ open class Storage(
return threadId ?: -1
}
override fun getContactWithSessionID(sessionID: String): Contact? {
return DatabaseComponent.get(context).sessionContactDatabase().getContactWithSessionID(sessionID)
override fun getContactWithAccountID(accountID: String): Contact? {
return DatabaseComponent.get(context).sessionContactDatabase().getContactWithAccountID(accountID)
}
override fun getAllContacts(): Set<Contact> {
@@ -1185,7 +1189,7 @@ open class Storage(
override fun setContact(contact: Contact) {
DatabaseComponent.get(context).sessionContactDatabase().setContact(contact)
val address = fromSerialized(contact.sessionID)
val address = fromSerialized(contact.accountID)
if (!getRecipientApproved(address)) return
val recipientHash = SSKEnvironment.shared.profileManager.contactUpdatedInternal(contact)
val recipient = Recipient.from(context, address, false)
@@ -1203,8 +1207,8 @@ open class Storage(
override fun addLibSessionContacts(contacts: List<LibSessionContact>, timestamp: Long) {
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null }
val id = AccountId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.accountId != null }
}
val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact ->
@@ -1256,8 +1260,8 @@ open class Storage(
val threadDatabase = DatabaseComponent.get(context).threadDatabase()
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = SessionId(contact.publicKey)
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.sessionId != null }
val id = AccountId(contact.publicKey)
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null }
}
for (contact in moreContacts) {
val address = fromSerialized(contact.publicKey)
@@ -1324,25 +1328,25 @@ open class Storage(
val threadRecipient = getRecipientForThread(threadID) ?: return
if (threadRecipient.isLocalNumber) {
val user = configFactory.user ?: return
user.setNtsPriority(if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE)
user.setNtsPriority(if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
} else if (threadRecipient.isContactRecipient) {
val contacts = configFactory.contacts ?: return
contacts.upsertContact(threadRecipient.address.serialize()) {
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
}
} else if (threadRecipient.isGroupRecipient) {
val groups = configFactory.userGroups ?: return
if (threadRecipient.isClosedGroupRecipient) {
val sessionId = GroupUtil.doubleDecodeGroupId(threadRecipient.address.serialize())
val newGroupInfo = groups.getOrConstructLegacyGroupInfo(sessionId).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
threadRecipient.address.serialize()
.let(GroupUtil::doubleDecodeGroupId)
.let(groups::getOrConstructLegacyGroupInfo)
.copy (priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE)
.let(groups::set)
} else if (threadRecipient.isCommunityRecipient) {
val openGroup = getOpenGroup(threadID) ?: return
val (baseUrl, room, pubKeyHex) = BaseCommunityInfo.parseFullUrl(openGroup.joinURL) ?: return
val newGroupInfo = groups.getOrConstructCommunityInfo(baseUrl, room, Hex.toStringCondensed(pubKeyHex)).copy (
priority = if (isPinned) PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE
priority = if (isPinned) PRIORITY_PINNED else PRIORITY_VISIBLE
)
groups.set(newGroupInfo)
}
@@ -1491,14 +1495,8 @@ open class Storage(
val address = recipient.address.serialize()
val blindedId = when {
recipient.isGroupRecipient -> null
recipient.isOpenGroupInboxRecipient -> {
GroupUtil.getDecodedOpenGroupInboxSessionId(address)
}
else -> {
if (SessionId(address).prefix == IdPrefix.BLINDED) {
address
} else null
}
recipient.isOpenGroupInboxRecipient -> GroupUtil.getDecodedOpenGroupInboxAccountId(address)
else -> address.takeIf { AccountId(it).prefix == IdPrefix.BLINDED }
} ?: continue
mappingDb.getBlindedIdMapping(blindedId).firstOrNull()?.let {
mappings[address] = it
@@ -1506,18 +1504,18 @@ open class Storage(
}
}
for (mapping in mappings) {
if (!SodiumUtilities.sessionId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) {
if (!SodiumUtilities.accountId(senderPublicKey, mapping.value.blindedId, mapping.value.serverId)) {
continue
}
mappingDb.addBlindedIdMapping(mapping.value.copy(sessionId = senderPublicKey))
mappingDb.addBlindedIdMapping(mapping.value.copy(accountId = senderPublicKey))
val blindedThreadId = threadDB.getOrCreateThreadIdFor(Recipient.from(context, fromSerialized(mapping.key), false))
mmsDb.updateThreadId(blindedThreadId, threadId)
smsDb.updateThreadId(blindedThreadId, threadId)
threadDB.deleteConversation(blindedThreadId)
}
recipientDb.setApproved(sender, true)
recipientDb.setApprovedMe(sender, true)
setRecipientApproved(sender, true)
setRecipientApprovedMe(sender, true)
val message = IncomingMediaMessage(
sender.address,
response.sentTimestamp!!,
@@ -1615,20 +1613,20 @@ open class Storage(
): BlindedIdMapping {
val db = DatabaseComponent.get(context).blindedIdMappingDatabase()
val mapping = db.getBlindedIdMapping(blindedId).firstOrNull() ?: BlindedIdMapping(blindedId, null, server, serverPublicKey)
if (mapping.sessionId != null) {
if (mapping.accountId != null) {
return mapping
}
getAllContacts().forEach { contact ->
val sessionId = SessionId(contact.sessionID)
if (sessionId.prefix == IdPrefix.STANDARD && SodiumUtilities.sessionId(sessionId.hexString, blindedId, serverPublicKey)) {
val contactMapping = mapping.copy(sessionId = sessionId.hexString)
val accountId = AccountId(contact.accountID)
if (accountId.prefix == IdPrefix.STANDARD && SodiumUtilities.accountId(accountId.hexString, blindedId, serverPublicKey)) {
val contactMapping = mapping.copy(accountId = accountId.hexString)
db.addBlindedIdMapping(contactMapping)
return contactMapping
}
}
db.getBlindedIdMappingsExceptFor(server).forEach {
if (SodiumUtilities.sessionId(it.sessionId!!, blindedId, serverPublicKey)) {
val otherMapping = mapping.copy(sessionId = it.sessionId)
if (SodiumUtilities.accountId(it.accountId!!, blindedId, serverPublicKey)) {
val otherMapping = mapping.copy(accountId = it.accountId)
db.addBlindedIdMapping(otherMapping)
return otherMapping
}
@@ -1744,7 +1742,7 @@ open class Storage(
if (recipient.isClosedGroupRecipient) {
val userGroups = configFactory.userGroups ?: return
val groupPublicKey = GroupUtil.addressToGroupSessionId(recipient.address)
val groupPublicKey = GroupUtil.addressToGroupAccountId(recipient.address)
val groupInfo = userGroups.getLegacyGroupInfo(groupPublicKey)
?.copy(disappearingTimer = expiryMode.expirySeconds) ?: return
userGroups.set(groupInfo)
@@ -1804,4 +1802,12 @@ open class Storage(
lokiDb.setLastLegacySenderAddress(recipientAddress, null)
}
}
}
}
/**
* Truncate a string to a specified number of bytes
*
* This could split multi-byte characters/emojis.
*/
private fun String.truncate(sizeInBytes: Int): String =
toByteArray().takeIf { it.size > sizeInBytes }?.take(sizeInBytes)?.toByteArray()?.let(::String) ?: this

View File

@@ -0,0 +1,6 @@
package org.thoughtcrime.securesms.database
import android.content.Context
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
fun Context.threadDatabase() = DatabaseComponent.get(this).threadDatabase()

View File

@@ -1,113 +0,0 @@
/*
* Copyright (C) 2012 Moxie Marlinspike
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.database.model;
import static java.util.Collections.emptyList;
import android.content.Context;
import android.text.SpannableString;
import androidx.annotation.NonNull;
import org.session.libsession.utilities.recipients.Recipient;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase.Status;
import org.thoughtcrime.securesms.mms.SlideDeck;
import network.loki.messenger.R;
/**
* Represents the message record model for MMS messages that are
* notifications (ie: they're pointers to undownloaded media).
*
* @author Moxie Marlinspike
*
*/
public class NotificationMmsMessageRecord extends MmsMessageRecord {
private final byte[] contentLocation;
private final long messageSize;
private final long expiry;
private final int status;
private final byte[] transactionId;
public NotificationMmsMessageRecord(long id, Recipient conversationRecipient,
Recipient individualRecipient,
long dateSent, long dateReceived, int deliveryReceiptCount,
long threadId, byte[] contentLocation, long messageSize,
long expiry, int status, byte[] transactionId, long mailbox,
SlideDeck slideDeck, int readReceiptCount, boolean hasMention)
{
super(id, "", conversationRecipient, individualRecipient,
dateSent, dateReceived, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
emptyList(), emptyList(),
0, 0, slideDeck, readReceiptCount, null, emptyList(), emptyList(), false, emptyList(), hasMention);
this.contentLocation = contentLocation;
this.messageSize = messageSize;
this.expiry = expiry;
this.status = status;
this.transactionId = transactionId;
}
public byte[] getTransactionId() {
return transactionId;
}
public int getStatus() {
return this.status;
}
public byte[] getContentLocation() {
return contentLocation;
}
public long getMessageSize() {
return (messageSize + 1023) / 1024;
}
public long getExpiration() {
return expiry * 1000;
}
@Override
public boolean isOutgoing() {
return false;
}
@Override
public boolean isPending() {
return false;
}
@Override
public boolean isMmsNotification() {
return true;
}
@Override
public boolean isMediaPending() {
return true;
}
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED) {
return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_multimedia_message));
} else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) {
return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_downloading_mms_message));
} else {
return emphasisAdded(context.getString(R.string.NotificationMmsMessageRecord_error_downloading_mms_message));
}
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright (C) 2012 Moxie Marlinspike
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.database.model
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.database.SmsDatabase
import org.thoughtcrime.securesms.mms.SlideDeck
/**
* Represents the message record model for MMS messages that are
* notifications (ie: they're pointers to undownloaded media).
*
* @author Moxie Marlinspike
*/
class NotificationMmsMessageRecord(
id: Long, conversationRecipient: Recipient?,
individualRecipient: Recipient?,
dateSent: Long,
dateReceived: Long,
deliveryReceiptCount: Int,
threadId: Long,
private val messageSize: Long,
private val expiry: Long,
val status: Int,
mailbox: Long,
slideDeck: SlideDeck?,
readReceiptCount: Int,
hasMention: Boolean
) : MmsMessageRecord(
id, "", conversationRecipient, individualRecipient,
dateSent, dateReceived, threadId, SmsDatabase.Status.STATUS_NONE, deliveryReceiptCount, mailbox,
emptyList(), emptyList(),
0, 0, slideDeck!!, readReceiptCount, null, emptyList(), emptyList(), false, emptyList(), hasMention
) {
fun getMessageSize(): Long {
return (messageSize + 1023) / 1024
}
val expiration: Long
get() = expiry * 1000
override fun isOutgoing(): Boolean {
return false
}
override fun isPending(): Boolean {
return false
}
override fun isMmsNotification(): Boolean {
return true
}
override fun isMediaPending(): Boolean {
return true
}
}

View File

@@ -1,101 +0,0 @@
package org.thoughtcrime.securesms.dms
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentEnterPublicKeyBinding
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.util.QRCodeUtilities
import org.thoughtcrime.securesms.util.hideKeyboard
import org.thoughtcrime.securesms.util.toPx
class EnterPublicKeyFragment : Fragment() {
private lateinit var binding: FragmentEnterPublicKeyBinding
var delegate: EnterPublicKeyDelegate? = null
private val hexEncodedPublicKey: String
get() {
return TextSecurePreferences.getLocalNumber(requireContext())!!
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentEnterPublicKeyBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding) {
publicKeyEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard
publicKeyEditText.setRawInputType(InputType.TYPE_CLASS_TEXT)
publicKeyEditText.setOnEditorActionListener { v, actionID, _ ->
if (actionID == EditorInfo.IME_ACTION_DONE) {
v.hideKeyboard()
handlePublicKeyEntered()
true
} else {
false
}
}
publicKeyEditText.addTextChangedListener { text -> createPrivateChatButton.isVisible = !text.isNullOrBlank() }
publicKeyEditText.setOnFocusChangeListener { _, hasFocus -> optionalContentContainer.isVisible = !hasFocus }
mainContainer.setOnTouchListener { _, _ ->
binding.optionalContentContainer.isVisible = true
publicKeyEditText.clearFocus()
publicKeyEditText.hideKeyboard()
true
}
val size = toPx(228, resources)
val qrCode = QRCodeUtilities.encode(hexEncodedPublicKey, size, isInverted = false, hasTransparentBackground = false)
qrCodeImageView.setImageBitmap(qrCode)
publicKeyTextView.text = hexEncodedPublicKey
publicKeyTextView.setOnCreateContextMenuListener { contextMenu, view, _ ->
contextMenu.add(0, view.id, 0, R.string.copy).setOnMenuItemClickListener {
copyPublicKey()
true
}
}
copyButton.setOnClickListener { copyPublicKey() }
shareButton.setOnClickListener { sharePublicKey() }
createPrivateChatButton.setOnClickListener { handlePublicKeyEntered(); publicKeyEditText.hideKeyboard() }
}
}
private fun copyPublicKey() {
val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", hexEncodedPublicKey)
clipboard.setPrimaryClip(clip)
Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
private fun sharePublicKey() {
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.putExtra(Intent.EXTRA_TEXT, hexEncodedPublicKey)
intent.type = "text/plain"
startActivity(intent)
}
private fun handlePublicKeyEntered() {
val hexEncodedPublicKey = binding.publicKeyEditText.text?.trim()?.toString()
if (hexEncodedPublicKey.isNullOrEmpty()) return
delegate?.handlePublicKeyEntered(hexEncodedPublicKey)
}
}
fun interface EnterPublicKeyDelegate {
fun handlePublicKeyEntered(publicKey: String)
}

View File

@@ -1,107 +0,0 @@
package org.thoughtcrime.securesms.dms
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.FragmentNewMessageBinding
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.PublicKeyValidation
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
@AndroidEntryPoint
class NewMessageFragment : Fragment() {
private lateinit var binding: FragmentNewMessageBinding
lateinit var delegate: NewConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = FragmentNewMessageBinding.inflate(inflater)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.backButton.setOnClickListener { delegate.onDialogBackPressed() }
binding.closeButton.setOnClickListener { delegate.onDialogClosePressed() }
val onsOrPkDelegate = { onsNameOrPublicKey: String -> createPrivateChatIfPossible(onsNameOrPublicKey)}
val adapter = NewMessageFragmentAdapter(
parentFragment = this,
enterPublicKeyDelegate = onsOrPkDelegate,
scanPublicKeyDelegate = onsOrPkDelegate
)
binding.viewPager.adapter = adapter
val mediator = TabLayoutMediator(binding.tabLayout, binding.viewPager) { tab, pos ->
tab.text = when (pos) {
0 -> getString(R.string.activity_create_private_chat_enter_session_id_tab_title)
1 -> getString(R.string.activity_create_private_chat_scan_qr_code_tab_title)
else -> throw IllegalStateException()
}
}
mediator.attach()
}
private fun createPrivateChatIfPossible(onsNameOrPublicKey: String) {
if (PublicKeyValidation.isValid(onsNameOrPublicKey)) {
createPrivateChat(onsNameOrPublicKey)
} else {
// This could be an ONS name
showLoader()
SnodeAPI.getSessionID(onsNameOrPublicKey).successUi { hexEncodedPublicKey ->
hideLoader()
createPrivateChat(hexEncodedPublicKey)
}.failUi { exception ->
hideLoader()
var message = getString(R.string.fragment_enter_public_key_error_message)
exception.localizedMessage?.let {
message = it
}
Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
}
}
}
private fun createPrivateChat(hexEncodedPublicKey: String) {
val recipient = Recipient.from(requireContext(), Address.fromSerialized(hexEncodedPublicKey), false)
val intent = Intent(requireContext(), ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, recipient.address)
intent.setDataAndType(requireActivity().intent.data, requireActivity().intent.type)
val existingThread = DatabaseComponent.get(requireContext()).threadDatabase().getThreadIdIfExistsFor(recipient)
intent.putExtra(ConversationActivityV2.THREAD_ID, existingThread)
requireContext().startActivity(intent)
delegate.onDialogClosePressed()
}
private fun showLoader() {
binding.loader.visibility = View.VISIBLE
binding.loader.animate().setDuration(150).alpha(1.0f).start()
}
private fun hideLoader() {
binding.loader.animate().setDuration(150).alpha(0.0f).setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
super.onAnimationEnd(animation)
binding.loader.visibility = View.GONE
}
})
}
}

View File

@@ -1,24 +0,0 @@
package org.thoughtcrime.securesms.dms
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
class NewMessageFragmentAdapter(
private val parentFragment: Fragment,
private val enterPublicKeyDelegate: EnterPublicKeyDelegate,
private val scanPublicKeyDelegate: ScanQRCodeWrapperFragmentDelegate
) : FragmentStateAdapter(parentFragment) {
override fun getItemCount(): Int = 2
override fun createFragment(position: Int): Fragment {
return when (position) {
0 -> EnterPublicKeyFragment().apply { delegate = enterPublicKeyDelegate }
1 -> ScanQRCodeWrapperFragment().apply { delegate = scanPublicKeyDelegate }
else -> throw IllegalStateException()
}
}
}

View File

@@ -25,7 +25,7 @@ import org.session.libsession.utilities.Device
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.contacts.SelectContactsAdapter
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
@@ -43,7 +43,7 @@ class CreateGroupFragment : Fragment() {
private lateinit var binding: FragmentCreateGroupBinding
private val viewModel: CreateGroupViewModel by viewModels()
lateinit var delegate: NewConversationDelegate
lateinit var delegate: StartConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,

View File

@@ -24,7 +24,7 @@ import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.OpenGroupUrlParser
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.conversation.start.NewConversationDelegate
import org.thoughtcrime.securesms.conversation.start.StartConversationDelegate
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
@@ -33,7 +33,7 @@ class JoinCommunityFragment : Fragment() {
private lateinit var binding: FragmentJoinCommunityBinding
lateinit var delegate: NewConversationDelegate
lateinit var delegate: StartConversationDelegate
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups
import android.content.Context
import androidx.annotation.WorkerThread
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.messaging.open_groups.GroupMemberRole
import org.session.libsession.messaging.open_groups.OpenGroup
@@ -143,9 +144,9 @@ object OpenGroupManager {
@WorkerThread
fun addOpenGroup(urlAsString: String, context: Context): OpenGroupApi.RoomInfo? {
val url = HttpUrl.parse(urlAsString) ?: return null
val url = urlAsString.toHttpUrlOrNull() ?: return null
val server = OpenGroup.getServer(urlAsString)
val room = url.pathSegments().firstOrNull() ?: return null
val room = url.pathSegments.firstOrNull() ?: return null
val publicKey = url.queryParameter("public_key") ?: return null
return add(server.toString().removeSuffix("/"), room, publicKey, context).second // assume migrated from calling function

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.text.SpannableString
import android.text.TextUtils
import android.util.AttributeSet
import android.util.TypedValue
@@ -16,13 +15,13 @@ import androidx.recyclerview.widget.RecyclerView
import dagger.hilt.android.AndroidEntryPoint
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewConversationBinding
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.conversation.v2.utilities.MentionUtilities.highlightMentions
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_ALL
import org.thoughtcrime.securesms.database.RecipientDatabase.NOTIFY_TYPE_NONE
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.getAccentColor
import org.thoughtcrime.securesms.util.getConversationUnread
@@ -50,7 +49,7 @@ class ConversationView : LinearLayout {
// endregion
// region Updating
fun bind(thread: ThreadRecord, isTyping: Boolean, glide: GlideRequests) {
fun bind(thread: ThreadRecord, isTyping: Boolean) {
this.thread = thread
if (thread.isPinned) {
binding.conversationViewDisplayNameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(
@@ -69,7 +68,7 @@ class ConversationView : LinearLayout {
}
val unreadCount = thread.unreadCount
if (thread.recipient.isBlocked) {
binding.accentView.setBackgroundResource(R.color.destructive)
binding.accentView.setBackgroundColor(ThemeUtil.getThemedColor(context, R.attr.danger))
binding.accentView.visibility = View.VISIBLE
} else {
val accentColor = context.getAccentColor()
@@ -122,7 +121,7 @@ class ConversationView : LinearLayout {
!thread.isOutgoing -> binding.statusIndicatorImageView.visibility = View.GONE
thread.isFailed -> {
val drawable = ContextCompat.getDrawable(context, R.drawable.ic_error)?.mutate()
drawable?.setTint(ContextCompat.getColor(context, R.color.destructive))
drawable?.setTint(ThemeUtil.getThemedColor(context, R.attr.danger))
binding.statusIndicatorImageView.setImageDrawable(drawable)
}
thread.isPending -> binding.statusIndicatorImageView.setImageResource(R.drawable.ic_circle_dot_dot_dot)
@@ -141,11 +140,10 @@ class ConversationView : LinearLayout {
else -> recipient.toShortString() // Internally uses the Contact API
}
private fun ThreadRecord.getSnippet(): CharSequence =
concatSnippet(getSnippetPrefix(), getDisplayBody(context))
private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence =
prefix?.let { TextUtils.concat(it, ": ", body) } ?: body
private fun ThreadRecord.getSnippet(): CharSequence = listOfNotNull(
getSnippetPrefix(),
getDisplayBody(context)
).joinToString(": ")
private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.home
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.theme.ThemeColors
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.theme.LocalType
@Composable
internal fun EmptyView(newAccount: Boolean) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = 50.dp)
) {
Spacer(modifier = Modifier.weight(1f))
Icon(
painter = painterResource(id = if (newAccount) R.drawable.emoji_tada_large else R.drawable.ic_logo_large),
contentDescription = null,
tint = Color.Unspecified
)
if (newAccount) {
Text(
stringResource(R.string.onboardingAccountCreated),
style = LocalType.current.h4,
textAlign = TextAlign.Center
)
Text(
stringResource(R.string.welcome_to_session),
style = LocalType.current.base,
color = LocalColors.current.primary,
textAlign = TextAlign.Center
)
}
Divider(modifier = Modifier.padding(vertical = LocalDimensions.current.smallSpacing))
Text(
stringResource(R.string.conversationsNone),
style = LocalType.current.h8,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = LocalDimensions.current.xsSpacing))
Text(
stringResource(R.string.onboardingHitThePlusButton),
style = LocalType.current.small,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.weight(2f))
}
}
@Preview
@Composable
fun PreviewEmptyView(
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(colors) {
EmptyView(newAccount = false)
}
}
@Preview
@Composable
fun PreviewEmptyViewNew(
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(colors) {
EmptyView(newAccount = true)
}
}

View File

@@ -4,13 +4,14 @@ import android.Manifest
import android.app.NotificationManager
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.text.SpannableString
import android.widget.Toast
import androidx.activity.viewModels
import androidx.core.os.bundleOf
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
@@ -18,11 +19,10 @@ import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.LinearLayoutManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
@@ -44,7 +44,7 @@ import org.session.libsignal.utilities.ThreadUtils
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.conversation.start.NewConversationFragment
import org.thoughtcrime.securesms.conversation.start.StartConversationFragment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivityV2
import org.thoughtcrime.securesms.conversation.v2.utilities.NotificationUtils
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
@@ -59,36 +59,35 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import org.thoughtcrime.securesms.groups.OpenGroupManager
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter
import org.thoughtcrime.securesms.home.search.GlobalSearchInputLayout
import org.thoughtcrime.securesms.home.search.GlobalSearchResult
import org.thoughtcrime.securesms.home.search.GlobalSearchViewModel
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.notifications.PushRegistry
import org.thoughtcrime.securesms.onboarding.SeedActivity
import org.thoughtcrime.securesms.onboarding.SeedReminderViewDelegate
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.preferences.SettingsActivity
import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity
import org.thoughtcrime.securesms.showMuteDialog
import org.thoughtcrime.securesms.showSessionDialog
import org.thoughtcrime.securesms.ui.setThemedContent
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.IP2Country
import org.thoughtcrime.securesms.util.disableClipping
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import org.thoughtcrime.securesms.util.start
import java.io.IOException
import javax.inject.Inject
private const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT"
private const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
@AndroidEntryPoint
class HomeActivity : PassphraseRequiredActionBarActivity(),
ConversationClickListener,
SeedReminderViewDelegate,
GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
companion object {
const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
}
private lateinit var binding: ActivityHomeBinding
private lateinit var glide: GlideRequests
@@ -104,8 +103,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val globalSearchViewModel by viewModels<GlobalSearchViewModel>()
private val homeViewModel by viewModels<HomeViewModel>()
private val publicKey: String
get() = textSecurePreferences.getLocalNumber()!!
private val publicKey: String by lazy { textSecurePreferences.getLocalNumber()!! }
private val homeAdapter: HomeAdapter by lazy {
HomeAdapter(context = this, configFactory = configFactory, listener = this, ::showMessageRequests, ::hideMessageRequests)
@@ -113,47 +111,37 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private val globalSearchAdapter = GlobalSearchAdapter { model ->
when (model) {
is GlobalSearchAdapter.Model.Message -> {
val threadId = model.messageResult.threadId
val timestamp = model.messageResult.sentTimestampMs
val author = model.messageResult.messageRecipient.address
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, timestamp)
intent.putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, author)
push(intent)
}
is GlobalSearchAdapter.Model.SavedMessages -> {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
push(intent)
}
is GlobalSearchAdapter.Model.Contact -> {
val address = model.contact.sessionID
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(address))
push(intent)
}
is GlobalSearchAdapter.Model.GroupConversation -> {
val groupAddress = Address.fromSerialized(model.groupRecord.encodedId)
val threadId = threadDb.getThreadIdIfExistsFor(Recipient.from(this, groupAddress, false))
if (threadId >= 0) {
val intent = Intent(this, ConversationActivityV2::class.java)
intent.putExtra(ConversationActivityV2.THREAD_ID, threadId)
push(intent)
is GlobalSearchAdapter.Model.Message -> push<ConversationActivityV2> {
model.messageResult.run {
putExtra(ConversationActivityV2.THREAD_ID, threadId)
putExtra(ConversationActivityV2.SCROLL_MESSAGE_ID, sentTimestampMs)
putExtra(ConversationActivityV2.SCROLL_MESSAGE_AUTHOR, messageRecipient.address)
}
}
else -> {
Log.d("Loki", "callback with model: $model")
is GlobalSearchAdapter.Model.SavedMessages -> push<ConversationActivityV2> {
putExtra(ConversationActivityV2.ADDRESS, Address.fromSerialized(model.currentUserPublicKey))
}
is GlobalSearchAdapter.Model.Contact -> push<ConversationActivityV2> {
putExtra(ConversationActivityV2.ADDRESS, model.contact.accountID.let(Address::fromSerialized))
}
is GlobalSearchAdapter.Model.GroupConversation -> model.groupRecord.encodedId
.let { Recipient.from(this, Address.fromSerialized(it), false) }
.let(threadDb::getThreadIdIfExistsFor)
.takeIf { it >= 0 }
?.let {
push<ConversationActivityV2> { putExtra(ConversationActivityV2.THREAD_ID, it) }
}
else -> Log.d("Loki", "callback with model: $model")
}
}
private val isFromOnboarding: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false)
private val isNewAccount: Boolean get() = intent.getBooleanExtra(NEW_ACCOUNT, false)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
// Set content view
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -164,20 +152,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Set up toolbar buttons
binding.profileButton.setOnClickListener { openSettings() }
binding.searchViewContainer.setOnClickListener {
globalSearchViewModel.refresh()
binding.globalSearchInputLayout.requestFocus()
}
binding.sessionToolbar.disableClipping()
// Set up seed reminder view
lifecycleScope.launchWhenStarted {
val hasViewedSeed = textSecurePreferences.getHasViewedSeed()
if (!hasViewedSeed) {
binding.seedReminderView.isVisible = true
binding.seedReminderView.title = SpannableString("You're almost finished! 80%") // Intentionally not yet translated
binding.seedReminderView.subtitle = resources.getString(R.string.view_seed_reminder_subtitle_1)
binding.seedReminderView.setProgress(80, false)
binding.seedReminderView.delegate = this@HomeActivity
} else {
binding.seedReminderView.isVisible = false
binding.seedReminderView.setThemedContent {
if (!textSecurePreferences.getHasViewedSeed()) SeedReminder { start<RecoveryPasswordActivity>() }
}
}
// Set up recycler view
@@ -193,11 +175,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
// Set up empty state view
binding.createNewPrivateChatButton.setOnClickListener { showNewConversation() }
binding.emptyStateContainer.setThemedContent {
EmptyView(isNewAccount)
}
IP2Country.configureIfNeeded(this@HomeActivity)
// Set up new conversation button
binding.newConversationButton.setOnClickListener { showNewConversation() }
binding.newConversationButton.setOnClickListener { showStartConversation() }
// Observe blocked contacts changed events
// subscribe to outdated config updates, this should be removed after long enough time for device migration
@@ -252,76 +237,95 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// monitor the global search VM query
launch {
binding.globalSearchInputLayout.query
.onEach(globalSearchViewModel::postQuery)
.collect()
.collect(globalSearchViewModel::setQuery)
}
// Get group results and display them
launch {
globalSearchViewModel.result.collect { result ->
val currentUserPublicKey = publicKey
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it) } +
result.threads.map { GlobalSearchAdapter.Model.GroupConversation(it) }
val contactResults = contactAndGroupList.toMutableList()
if (contactResults.isEmpty()) {
contactResults.add(GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey))
globalSearchViewModel.result.map { result ->
result.query to when {
result.query.isEmpty() -> buildList {
add(GlobalSearchAdapter.Model.Header(R.string.contacts))
add(GlobalSearchAdapter.Model.SavedMessages(publicKey))
addAll(result.groupedContacts)
}
else -> buildList {
result.contactAndGroupList.takeUnless { it.isEmpty() }?.let {
add(GlobalSearchAdapter.Model.Header(R.string.conversations))
addAll(it)
}
result.messageResults.takeUnless { it.isEmpty() }?.let {
add(GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
addAll(it)
}
}
}
val userIndex = contactResults.indexOfFirst { it is GlobalSearchAdapter.Model.Contact && it.contact.sessionID == currentUserPublicKey }
if (userIndex >= 0) {
contactResults[userIndex] = GlobalSearchAdapter.Model.SavedMessages(currentUserPublicKey)
}
if (contactResults.isNotEmpty()) {
contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_contacts_groups))
}
val unreadThreadMap = result.messages
.groupBy { it.threadId }.keys
.map { it to mmsSmsDatabase.getUnreadCount(it) }
.toMap()
val messageResults: MutableList<GlobalSearchAdapter.Model> = result.messages
.map { messageResult ->
GlobalSearchAdapter.Model.Message(
messageResult,
unreadThreadMap[messageResult.threadId] ?: 0
)
}.toMutableList()
if (messageResults.isNotEmpty()) {
messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
}
val newData = contactResults + messageResults
globalSearchAdapter.setNewData(result.query, newData)
}
}.collectLatest(globalSearchAdapter::setNewData)
}
}
EventBus.getDefault().register(this@HomeActivity)
if (intent.hasExtra(FROM_ONBOARDING)
&& intent.getBooleanExtra(FROM_ONBOARDING, false)) {
if (isFromOnboarding) {
if (Build.VERSION.SDK_INT >= 33 &&
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled().not()) {
Permissions.with(this)
.request(Manifest.permission.POST_NOTIFICATIONS)
.execute()
}
configFactory.user?.let { user ->
if (!user.isBlockCommunityMessageRequestsSet()) {
user.setCommunityMessageRequests(false)
configFactory.user
?.takeUnless { it.isBlockCommunityMessageRequestsSet() }
?.setCommunityMessageRequests(false)
}
}
private val GlobalSearchResult.groupedContacts: List<GlobalSearchAdapter.Model> get() {
class NamedValue<T>(val name: String?, val value: T)
// Unknown is temporarily to be grouped together with numbers title.
// https://optf.atlassian.net/browse/SES-2287
val numbersTitle = "#"
val unknownTitle = numbersTitle
return contacts
// Remove ourself, we're shown above.
.filter { it.accountID != publicKey }
// Get the name that we will display and sort by, and uppercase it to
// help with sorting and we need the char uppercased later.
.map { (it.nickname?.takeIf(String::isNotEmpty) ?: it.name?.takeIf(String::isNotEmpty))
.let { name -> NamedValue(name?.uppercase(), it) } }
// Digits are all grouped under a #, the rest are grouped by their first character.uppercased()
// If there is no name, they go under Unknown
.groupBy { it.name?.run { first().takeUnless(Char::isDigit)?.toString() ?: numbersTitle } ?: unknownTitle }
// place the # at the end, after all the names starting with alphabetic chars
.toSortedMap(compareBy {
when (it) {
unknownTitle -> Char.MAX_VALUE
numbersTitle -> Char.MAX_VALUE - 1
else -> it.first()
}
})
// Flatten the map of char to lists into an actual List that can be displayed.
.flatMap { (key, contacts) ->
listOf(
GlobalSearchAdapter.Model.SubHeader(key)
) + contacts.sortedBy { it.name ?: it.value.accountID }.map { it.value }.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) }
}
}
private val GlobalSearchResult.contactAndGroupList: List<GlobalSearchAdapter.Model> get() =
contacts.map { GlobalSearchAdapter.Model.Contact(it, it.nickname ?: it.name, it.accountID == publicKey) } +
threads.map(GlobalSearchAdapter.Model::GroupConversation)
private val GlobalSearchResult.messageResults: List<GlobalSearchAdapter.Model> get() {
val unreadThreadMap = messages
.map { it.threadId }.toSet()
.associateWith { mmsSmsDatabase.getUnreadCount(it) }
return messages.map {
GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0, it.conversationRecipient.isLocalNumber)
}
}
override fun onInputFocusChanged(hasFocus: Boolean) {
if (hasFocus) {
setSearchShown(true)
} else {
setSearchShown(!binding.globalSearchInputLayout.query.value.isNullOrEmpty())
}
setSearchShown(hasFocus || binding.globalSearchInputLayout.query.value.isNotEmpty())
}
private fun setSearchShown(isShown: Boolean) {
@@ -330,7 +334,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
binding.recyclerView.isVisible = !isShown
binding.emptyStateContainer.isVisible = (binding.recyclerView.adapter as HomeAdapter).itemCount == 0 && binding.recyclerView.isVisible
binding.seedReminderView.isVisible = !TextSecurePreferences.getHasViewedSeed(this) && !isShown
binding.globalSearchRecycler.isVisible = isShown
binding.globalSearchRecycler.isInvisible = !isShown
binding.newConversationButton.isVisible = !isShown
}
@@ -351,12 +355,9 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
updateLegacyConfigView()
// TODO: remove this after enough updates that we can rely on ConfigBase.isNewConfigEnabled to always return true
// This will only run if we aren't using new configs, as they are schedule to sync when there are changes applied
if (textSecurePreferences.getConfigurationMessageSynced()) {
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
}
// Sync config changes if there are any
lifecycleScope.launch(Dispatchers.IO) {
ConfigurationMessageUtilities.syncConfigurationIfNeeded(this@HomeActivity)
}
}
@@ -397,16 +398,8 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// region Interaction
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (binding.globalSearchRecycler.isVisible) {
binding.globalSearchInputLayout.clearSearch(true)
return
}
super.onBackPressed()
}
override fun handleSeedReminderViewContinueButtonTapped() {
val intent = Intent(this, SeedActivity::class.java)
show(intent)
if (binding.globalSearchRecycler.isVisible) binding.globalSearchInputLayout.clearSearch(true)
else super.onBackPressed()
}
override fun onConversationClick(thread: ThreadRecord) {
@@ -431,17 +424,17 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
bottomSheet.onCopyConversationId = onCopyConversationId@{
bottomSheet.dismiss()
if (!thread.recipient.isGroupRecipient && !thread.recipient.isLocalNumber) {
val clip = ClipData.newPlainText("Session ID", thread.recipient.address.toString())
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Account ID", thread.recipient.address.toString())
val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
else if (thread.recipient.isCommunityRecipient) {
val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient) ?: return@onCopyConversationId Unit
val threadId = threadDb.getThreadIdIfExistsFor(thread.recipient)
val openGroup = DatabaseComponent.get(this@HomeActivity).lokiThreadDatabase().getOpenGroupChat(threadId) ?: return@onCopyConversationId Unit
val clip = ClipData.newPlainText("Community URL", openGroup.joinURL)
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
val manager = getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()
}
@@ -569,7 +562,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
val message = if (recipient.isGroupRecipient) {
val group = groupDatabase.getGroup(recipient.address.toString()).orNull()
if (group != null && group.admins.map { it.toString() }.contains(textSecurePreferences.getLocalNumber())) {
"Because you are the creator of this group it will be deleted for everyone. This cannot be undone."
getString(R.string.admin_group_leave_warning)
} else {
resources.getString(R.string.activity_home_leave_group_dialog_message)
}
@@ -625,7 +618,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
private fun hideMessageRequests() {
showSessionDialog {
text("Hide message requests?")
text(getString(R.string.hide_message_requests))
button(R.string.yes) {
textSecurePreferences.setHasHiddenMessageRequests()
homeViewModel.tryReload()
@@ -634,9 +627,15 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
private fun showNewConversation() {
NewConversationFragment().show(supportFragmentManager, "NewConversationFragment")
private fun showStartConversation() {
StartConversationFragment().show(supportFragmentManager, "StartConversationFragment")
}
// endregion
}
fun Context.startHomeActivity(isFromOnboarding: Boolean, isNewAccount: Boolean) {
Intent(this, HomeActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(NEW_ACCOUNT, isNewAccount)
putExtra(FROM_ONBOARDING, isFromOnboarding)
}.also(::startActivity)
}

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.home
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListUpdateCallback
@@ -12,8 +11,6 @@ import network.loki.messenger.R
import network.loki.messenger.databinding.ViewMessageRequestBannerBinding
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
class HomeAdapter(
private val context: Context,
@@ -115,7 +112,7 @@ class HomeAdapter(
val offset = if (hasHeaderView()) position - 1 else position
val thread = data.threads[offset]
val isTyping = data.typingThreadIDs.contains(thread.threadId)
holder.view.bind(thread, isTyping, glide)
holder.view.bind(thread, isTyping)
}
}
}

View File

@@ -0,0 +1,83 @@
package org.thoughtcrime.securesms.home
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import network.loki.messenger.R
import org.thoughtcrime.securesms.ui.theme.LocalDimensions
import org.thoughtcrime.securesms.ui.theme.PreviewTheme
import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider
import org.thoughtcrime.securesms.ui.SessionShieldIcon
import org.thoughtcrime.securesms.ui.theme.ThemeColors
import org.thoughtcrime.securesms.ui.theme.LocalColors
import org.thoughtcrime.securesms.ui.components.SlimPrimaryOutlineButton
import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.theme.LocalType
@Composable
internal fun SeedReminder(startRecoveryPasswordActivity: () -> Unit) {
Column {
// Color Strip
Box(
Modifier
.fillMaxWidth()
.height(LocalDimensions.current.indicatorHeight)
.background(LocalColors.current.primary)
)
Row(
Modifier
.background(LocalColors.current.backgroundSecondary)
.padding(
horizontal = LocalDimensions.current.spacing,
vertical = LocalDimensions.current.smallSpacing
)
) {
Column(Modifier.weight(1f)) {
Row {
Text(
stringResource(R.string.save_your_recovery_password),
style = LocalType.current.h8
)
Spacer(Modifier.requiredWidth(LocalDimensions.current.xxsSpacing))
SessionShieldIcon()
}
Text(
stringResource(R.string.save_your_recovery_password_to_make_sure_you_don_t_lose_access_to_your_account),
style = LocalType.current.small
)
}
Spacer(Modifier.width(LocalDimensions.current.xsSpacing))
SlimPrimaryOutlineButton(
text = stringResource(R.string.continue_2),
modifier = Modifier
.align(Alignment.CenterVertically)
.contentDescription(R.string.AccessibilityId_reveal_recovery_phrase_button),
onClick = startRecoveryPasswordActivity
)
}
}
}
@Preview
@Composable
private fun PreviewSeedReminder(
@PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors
) {
PreviewTheme(colors) {
SeedReminder {}
}
}

View File

@@ -56,7 +56,6 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
val threadRecipient = threadDb.getRecipientForThreadId(threadID) ?: return dismiss()
with(binding) {
profilePictureView.publicKey = publicKey
profilePictureView.isLarge = true
profilePictureView.update(recipient)
nameTextViewContainer.visibility = View.VISIBLE
nameTextViewContainer.setOnClickListener {
@@ -99,7 +98,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
publicKeyTextView.setOnLongClickListener {
val clipboard =
requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Session ID", publicKey)
val clip = ClipData.newPlainText("Account ID", publicKey)
clipboard.setPrimaryClip(clip)
Toast.makeText(requireContext(), R.string.copied_to_clipboard, Toast.LENGTH_SHORT)
.show()
@@ -138,7 +137,7 @@ class UserDetailsBottomSheet: BottomSheetDialogFragment() {
else { newNickName = previousContactNickname }
val publicKey = recipient.address.serialize()
val storage = MessagingModuleConfiguration.shared.storage
val contact = storage.getContactWithSessionID(publicKey) ?: Contact(publicKey)
val contact = storage.getContactWithAccountID(publicKey) ?: Contact(publicKey)
contact.nickname = newNickName
storage.setContact(contact)
nameTextView.text = recipient.name ?: publicKey // Uses the Contact API internally

View File

@@ -9,23 +9,27 @@ import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewGlobalSearchHeaderBinding
import network.loki.messenger.databinding.ViewGlobalSearchResultBinding
import network.loki.messenger.databinding.ViewGlobalSearchSubheaderBinding
import org.session.libsession.utilities.GroupRecord
import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.ui.GetString
import java.security.InvalidParameterException
import org.session.libsession.messaging.contacts.Contact as ContactModel
class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
const val HEADER_VIEW_TYPE = 0
const val CONTENT_VIEW_TYPE = 1
const val SUB_HEADER_VIEW_TYPE = 1
const val CONTENT_VIEW_TYPE = 2
}
private var data: List<Model> = listOf()
private var query: String? = null
fun setNewData(data: Pair<String, List<Model>>) = setNewData(data.first, data.second)
fun setNewData(query: String, newData: List<Model>) {
val diffResult = DiffUtil.calculateDiff(GlobalSearchDiff(this.query, query, data, newData))
this.query = query
@@ -34,21 +38,26 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
}
override fun getItemViewType(position: Int): Int =
if (data[position] is Model.Header) HEADER_VIEW_TYPE else CONTENT_VIEW_TYPE
when(data[position]) {
is Model.Header -> HEADER_VIEW_TYPE
is Model.SubHeader -> SUB_HEADER_VIEW_TYPE
else -> CONTENT_VIEW_TYPE
}
override fun getItemCount(): Int = data.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
if (viewType == HEADER_VIEW_TYPE) {
HeaderView(
LayoutInflater.from(parent.context)
.inflate(R.layout.view_global_search_header, parent, false)
when (viewType) {
HEADER_VIEW_TYPE -> HeaderView(
LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_header, parent, false)
)
SUB_HEADER_VIEW_TYPE -> SubHeaderView(
LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_subheader, parent, false)
)
else -> ContentView(
LayoutInflater.from(parent.context).inflate(R.layout.view_global_search_result, parent, false),
modelCallback
)
} else {
ContentView(
LayoutInflater.from(parent.context)
.inflate(R.layout.view_global_search_result, parent, false)
, modelCallback)
}
override fun onBindViewHolder(
@@ -61,10 +70,10 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
holder.bindPayload(newUpdateQuery, data[position])
return
}
if (holder is HeaderView) {
holder.bind(data[position] as Model.Header)
} else if (holder is ContentView) {
holder.bind(query.orEmpty(), data[position])
when (holder) {
is HeaderView -> holder.bind(data[position] as Model.Header)
is SubHeaderView -> holder.bind(data[position] as Model.SubHeader)
is ContentView -> holder.bind(query.orEmpty(), data[position])
}
}
@@ -77,7 +86,16 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
val binding = ViewGlobalSearchHeaderBinding.bind(view)
fun bind(header: Model.Header) {
binding.searchHeader.setText(header.title)
binding.searchHeader.setText(header.title.string(binding.root.context))
}
}
class SubHeaderView(view: View) : RecyclerView.ViewHolder(view) {
val binding = ViewGlobalSearchSubheaderBinding.bind(view)
fun bind(header: Model.SubHeader) {
binding.searchHeader.text = header.title.string(binding.root.context)
}
}
@@ -102,25 +120,24 @@ class GlobalSearchAdapter (private val modelCallback: (Model)->Unit): RecyclerVi
is Model.Contact -> bindModel(query, model)
is Model.Message -> bindModel(query, model)
is Model.SavedMessages -> bindModel(model)
is Model.Header -> throw InvalidParameterException("Can't display Model.Header as ContentView")
else -> throw InvalidParameterException("Can't display as ContentView")
}
binding.root.setOnClickListener { modelCallback(model) }
}
}
data class MessageModel(
val threadRecipient: Recipient,
val messageRecipient: Recipient,
val messageSnippet: String
)
sealed class Model {
data class Header(@StringRes val title: Int) : Model()
data class Header(val title: GetString): Model() {
constructor(@StringRes title: Int): this(GetString(title))
constructor(title: String): this(GetString(title))
}
data class SubHeader(val title: GetString): Model() {
constructor(@StringRes title: Int): this(GetString(title))
constructor(title: String): this(GetString(title))
}
data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel) : Model()
data class GroupConversation(val groupRecord: GroupRecord) : Model()
data class Message(val messageResult: MessageResult, val unread: Int) : Model()
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model()
data class GroupConversation(val groupRecord: GroupRecord): Model()
data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model()
}
}
}

View File

@@ -10,11 +10,13 @@ import network.loki.messenger.R
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.recipients.Recipient
import org.session.libsession.utilities.truncateIdForDisplay
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.ContentView
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.GroupConversation
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Header
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.Message
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SavedMessages
import org.thoughtcrime.securesms.home.search.GlobalSearchAdapter.Model.SubHeader
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SearchUtil
import java.util.Locale
@@ -63,7 +65,7 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
))
binding.searchResultSubtitle.text = textSpannable
binding.searchResultSubtitle.isVisible = true
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
binding.searchResultTitle.text = model.messageResult.conversationRecipient.getSearchName()
}
is GroupConversation -> {
binding.searchResultTitle.text = getHighlight(
@@ -72,12 +74,12 @@ fun ContentView.bindQuery(query: String, model: GlobalSearchAdapter.Model) {
)
val membersString = model.groupRecord.members.joinToString { address ->
val recipient = Recipient.from(binding.root.context, address, false)
recipient.name ?: "${address.serialize().take(4)}...${address.serialize().takeLast(4)}"
Recipient.from(binding.root.context, address, false).getSearchName()
}
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
is Header, // do nothing for header
is SubHeader, // do nothing for subheader
is SavedMessages -> Unit // do nothing for saved messages (displays note to self)
}
}
@@ -88,7 +90,6 @@ private fun getHighlight(query: String?, toSearch: String): Spannable? {
fun ContentView.bindModel(query: String?, model: GroupConversation) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = model.groupRecord.isClosedGroup
binding.searchResultTimestamp.isVisible = false
val threadRecipient = Recipient.from(binding.root.context, Address.fromSerialized(model.groupRecord.encodedId), false)
@@ -98,64 +99,65 @@ fun ContentView.bindModel(query: String?, model: GroupConversation) {
val groupRecipients = model.groupRecord.members.map { Recipient.from(binding.root.context, it, false) }
val membersString = groupRecipients.joinToString {
val address = it.address.serialize()
it.name ?: "${address.take(4)}...${address.takeLast(4)}"
}
val membersString = groupRecipients.joinToString(transform = Recipient::getSearchName)
if (model.groupRecord.isClosedGroup) {
binding.searchResultSubtitle.text = getHighlight(query, membersString)
}
}
fun ContentView.bindModel(query: String?, model: ContactModel) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false
binding.searchResultSubtitle.text = null
val recipient =
Recipient.from(binding.root.context, Address.fromSerialized(model.contact.sessionID), false)
binding.searchResultProfilePicture.update(recipient)
val nameString = model.contact.getSearchName()
binding.searchResultTitle.text = getHighlight(query, nameString)
fun ContentView.bindModel(query: String?, model: ContactModel) = binding.run {
searchResultProfilePicture.isVisible = true
searchResultSubtitle.isVisible = false
searchResultTimestamp.isVisible = false
searchResultSubtitle.text = null
val recipient = Recipient.from(root.context, Address.fromSerialized(model.contact.accountID), false)
searchResultProfilePicture.update(recipient)
val nameString = if (model.isSelf) root.context.getString(R.string.note_to_self)
else model.contact.getSearchName()
searchResultTitle.text = getHighlight(query, nameString)
}
fun ContentView.bindModel(model: SavedMessages) {
binding.searchResultSubtitle.isVisible = false
binding.searchResultTimestamp.isVisible = false
binding.searchResultTitle.setText(R.string.note_to_self)
binding.searchResultProfilePicture.isVisible = false
binding.searchResultSavedMessages.isVisible = true
binding.searchResultProfilePicture.update(Address.fromSerialized(model.currentUserPublicKey))
binding.searchResultProfilePicture.isVisible = true
}
fun ContentView.bindModel(query: String?, model: Message) {
binding.searchResultProfilePicture.isVisible = true
binding.searchResultSavedMessages.isVisible = false
binding.searchResultTimestamp.isVisible = true
fun ContentView.bindModel(query: String?, model: Message) = binding.apply {
searchResultProfilePicture.isVisible = true
searchResultTimestamp.isVisible = true
// val hasUnreads = model.unread > 0
// binding.unreadCountIndicator.isVisible = hasUnreads
// unreadCountIndicator.isVisible = hasUnreads
// if (hasUnreads) {
// binding.unreadCountTextView.text = model.unread.toString()
// unreadCountTextView.text = model.unread.toString()
// }
binding.searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(binding.root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
binding.searchResultProfilePicture.update(model.messageResult.conversationRecipient)
searchResultTimestamp.text = DateUtils.getDisplayFormattedTimeSpanString(root.context, Locale.getDefault(), model.messageResult.sentTimestampMs)
searchResultProfilePicture.update(model.messageResult.conversationRecipient)
val textSpannable = SpannableStringBuilder()
if (model.messageResult.conversationRecipient != model.messageResult.messageRecipient) {
// group chat, bind
val text = "${model.messageResult.messageRecipient.getSearchName()}: "
val text = "${model.messageResult.messageRecipient.toShortString()}: "
textSpannable.append(text)
}
textSpannable.append(getHighlight(
query,
model.messageResult.bodySnippet
))
binding.searchResultSubtitle.text = textSpannable
binding.searchResultTitle.text = model.messageResult.conversationRecipient.toShortString()
binding.searchResultSubtitle.isVisible = true
searchResultSubtitle.text = textSpannable
searchResultTitle.text = if (model.isSelf) root.context.getString(R.string.note_to_self)
else model.messageResult.conversationRecipient.getSearchName()
searchResultSubtitle.isVisible = true
}
fun Recipient.getSearchName(): String = name ?: address.serialize().let { address -> "${address.take(4)}...${address.takeLast(4)}" }
fun Recipient.getSearchName(): String =
name?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId }
?: address.serialize().let(::truncateIdForDisplay)
fun Contact.getSearchName(): String =
if (nickname.isNullOrEmpty()) name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"
else "${name ?: "${sessionID.take(4)}...${sessionID.takeLast(4)}"} ($nickname)"
nickname?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId }
?: name?.takeIf { it.isNotEmpty() && !it.looksLikeAccountId }
?: truncateIdForDisplay(accountID)
private val String.looksLikeAccountId: Boolean get() = length > 60 && all { it.isDigit() || it.isLetter() }

View File

@@ -16,42 +16,37 @@ import android.widget.TextView
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import network.loki.messenger.databinding.ViewGlobalSearchInputBinding
import org.thoughtcrime.securesms.util.SimpleTextWatcher
import org.thoughtcrime.securesms.util.addTextChangedListener
class GlobalSearchInputLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : LinearLayout(context, attrs),
View.OnFocusChangeListener,
View.OnClickListener,
TextWatcher, TextView.OnEditorActionListener {
TextView.OnEditorActionListener {
var binding: ViewGlobalSearchInputBinding = ViewGlobalSearchInputBinding.inflate(LayoutInflater.from(context), this, true)
var listener: GlobalSearchInputLayoutListener? = null
private val _query = MutableStateFlow<CharSequence?>(null)
val query: StateFlow<CharSequence?> = _query
private val _query = MutableStateFlow<CharSequence>("")
val query: StateFlow<CharSequence> = _query
override fun onAttachedToWindow() {
super.onAttachedToWindow()
binding.searchInput.onFocusChangeListener = this
binding.searchInput.addTextChangedListener(this)
binding.searchInput.addTextChangedListener(::setQuery)
binding.searchInput.setOnEditorActionListener(this)
binding.searchInput.setFilters( arrayOf<InputFilter>(LengthFilter(100)) ) // 100 char search limit
binding.searchCancel.setOnClickListener(this)
binding.searchClear.setOnClickListener(this)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
binding.searchInput.filters = arrayOf<InputFilter>(LengthFilter(100)) // 100 char search limit
binding.searchCancel.setOnClickListener { clearSearch(true) }
binding.searchClear.setOnClickListener { clearSearch(false) }
}
override fun onFocusChange(v: View?, hasFocus: Boolean) {
if (v === binding.searchInput) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (!hasFocus) {
imm.hideSoftInputFromWindow(windowToken, 0)
} else {
imm.showSoftInput(v, 0)
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).apply {
if (hasFocus) showSoftInput(v, 0)
else hideSoftInputFromWindow(windowToken, 0)
}
listener?.onInputFocusChanged(hasFocus)
}
@@ -65,27 +60,16 @@ class GlobalSearchInputLayout @JvmOverloads constructor(
return false
}
override fun onClick(v: View?) {
if (v === binding.searchCancel) {
clearSearch(true)
} else if (v === binding.searchClear) {
clearSearch(false)
}
}
fun clearSearch(clearFocus: Boolean) {
binding.searchInput.text = null
setQuery("")
if (clearFocus) {
binding.searchInput.clearFocus()
}
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
_query.value = s?.toString()
private fun setQuery(query: String) {
_query.value = query
}
interface GlobalSearchInputLayoutListener {

View File

@@ -2,33 +2,25 @@ package org.thoughtcrime.securesms.home.search
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.GroupRecord
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.search.model.MessageResult
import org.thoughtcrime.securesms.search.model.SearchResult
data class GlobalSearchResult(
val query: String,
val contacts: List<Contact>,
val threads: List<GroupRecord>,
val messages: List<MessageResult>
val query: String,
val contacts: List<Contact> = emptyList(),
val threads: List<GroupRecord> = emptyList(),
val messages: List<MessageResult> = emptyList()
) {
val isEmpty: Boolean
get() = contacts.isEmpty() && threads.isEmpty() && messages.isEmpty()
companion object {
val EMPTY = GlobalSearchResult("", emptyList(), emptyList(), emptyList())
const val SEARCH_LIMIT = 5
fun from(searchResult: SearchResult): GlobalSearchResult {
val query = searchResult.query
val contactList = searchResult.contacts.toList()
val threads = searchResult.conversations.toList()
val messages = searchResult.messages.toList()
searchResult.close()
return GlobalSearchResult(query, contactList, threads, messages)
}
val EMPTY = GlobalSearchResult("")
}
}
fun SearchResult.toGlobalSearchResult(): GlobalSearchResult = try {
GlobalSearchResult(query, contacts.toList(), conversations.toList(), messages.toList())
} finally {
close()
}

View File

@@ -3,15 +3,22 @@ package org.thoughtcrime.securesms.home.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.session.libsignal.utilities.SettableFuture
import org.thoughtcrime.securesms.search.SearchRepository
@@ -19,49 +26,51 @@ import org.thoughtcrime.securesms.search.model.SearchResult
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class GlobalSearchViewModel @Inject constructor(private val searchRepository: SearchRepository) : ViewModel() {
class GlobalSearchViewModel @Inject constructor(
private val searchRepository: SearchRepository,
) : ViewModel() {
private val scope = viewModelScope + SupervisorJob()
private val refreshes = MutableSharedFlow<Unit>()
private val _queryText = MutableStateFlow<CharSequence>("")
private val executor = viewModelScope + SupervisorJob()
val result = _queryText
.reEmit(refreshes)
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query ->
if (query.trim().isEmpty()) {
// searching for 05 as contactDb#getAllContacts was not returning contacts
// without a nickname/name who haven't approved us.
GlobalSearchResult(query.toString(), searchRepository.queryContacts("05").first.toList())
} else {
// User input delay in case we get a new query within a few hundred ms this
// coroutine will be cancelled and the expensive query will not be run.
delay(300)
val settableFuture = SettableFuture<SearchResult>()
searchRepository.query(query.toString(), settableFuture::set)
try {
// search repository doesn't play nicely with suspend functions (yet)
settableFuture.get(10_000, TimeUnit.MILLISECONDS).toGlobalSearchResult()
} catch (e: Exception) {
GlobalSearchResult(query.toString())
}
}
}
private val _result: MutableStateFlow<GlobalSearchResult> = MutableStateFlow(GlobalSearchResult.EMPTY)
val result: StateFlow<GlobalSearchResult> = _result
private val _queryText: MutableStateFlow<CharSequence> = MutableStateFlow("")
fun postQuery(charSequence: CharSequence?) {
charSequence ?: return
fun setQuery(charSequence: CharSequence) {
_queryText.value = charSequence
}
init {
//
_queryText
.buffer(onBufferOverflow = BufferOverflow.DROP_OLDEST)
.mapLatest { query ->
// Early exit on empty search query
if (query.trim().isEmpty()) {
SearchResult.EMPTY
} else {
// User input delay in case we get a new query within a few hundred ms this
// coroutine will be cancelled and the expensive query will not be run.
delay(300)
val settableFuture = SettableFuture<SearchResult>()
searchRepository.query(query.toString(), settableFuture::set)
try {
// search repository doesn't play nicely with suspend functions (yet)
settableFuture.get(10_000, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
SearchResult.EMPTY
}
}
}
.onEach { result ->
// update the latest _result value
_result.value = GlobalSearchResult.from(result)
}
.launchIn(executor)
fun refresh() {
viewModelScope.launch {
refreshes.emit(Unit)
}
}
}
}
/**
* Re-emit whenever refreshes emits.
* */
@OptIn(ExperimentalCoroutinesApi::class)
private fun <T> Flow<T>.reEmit(refreshes: Flow<Unit>) = flatMapLatest { query -> merge(flowOf(query), refreshes.map { query }) }

View File

@@ -35,6 +35,5 @@ public class AndroidLogger extends Log.Logger {
}
@Override
public void blockUntilAllWritesFinished() {
}
public void blockUntilAllWritesFinished() { }
}

View File

@@ -34,13 +34,13 @@ class MessageRequestView : LinearLayout {
// region Updating
fun bind(thread: ThreadRecord, glide: GlideRequests) {
this.thread = thread
val senderDisplayName = getUserDisplayName(thread.recipient)
?: thread.recipient.address.toString()
val senderDisplayName = getUserDisplayName(thread.recipient) ?: thread.recipient.address.toString()
binding.displayNameTextView.text = senderDisplayName
binding.timestampTextView.text = DateUtils.getDisplayFormattedTimeSpanString(context, Locale.getDefault(), thread.date)
val rawSnippet = thread.getDisplayBody(context)
val snippet = highlightMentions(
text = rawSnippet,
text = thread.getDisplayBody(context),
formatOnly = true, // no styling here, only text formatting
threadID = thread.threadId,
context = context

View File

@@ -1,15 +1,17 @@
package org.thoughtcrime.securesms.messagerequests
import android.content.Context
import android.content.res.ColorStateList
import android.database.Cursor
import android.os.Build
import android.text.SpannableString
import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.core.graphics.drawable.DrawableCompat
import androidx.recyclerview.widget.RecyclerView
import network.loki.messenger.R
import org.session.libsession.utilities.ThemeUtil
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter
import org.thoughtcrime.securesms.database.model.ThreadRecord
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
@@ -60,11 +62,19 @@ class MessageRequestsAdapter(
for (i in 0 until popupMenu.menu.size()) {
val item = popupMenu.menu.getItem(i)
val s = SpannableString(item.title)
s.setSpan(ForegroundColorSpan(context.getColor(R.color.destructive)), 0, s.length, 0)
item.iconTintList = ColorStateList.valueOf(context.getColor(R.color.destructive))
val danger = ThemeUtil.getThemedColor(context, R.attr.danger)
s.setSpan(ForegroundColorSpan(danger), 0, s.length, 0)
item.icon?.let {
DrawableCompat.setTint(
it,
danger
)
}
item.title = s
}
popupMenu.setForceShowIcon(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
popupMenu.setForceShowIcon(true)
}
popupMenu.show()
}

View File

@@ -1,207 +0,0 @@
/**
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.mms;
import android.content.Context;
import android.content.res.Resources.Theme;
import android.net.Uri;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.session.libsignal.utilities.guava.Optional;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment;
import org.session.libsession.utilities.Util;
import java.security.SecureRandom;
import network.loki.messenger.R;
public abstract class Slide {
protected final Attachment attachment;
protected final Context context;
public Slide(@NonNull Context context, @NonNull Attachment attachment) {
this.context = context;
this.attachment = attachment;
}
public String getContentType() {
return attachment.getContentType();
}
@Nullable
public Uri getUri() {
return attachment.getDataUri();
}
@Nullable
public Uri getThumbnailUri() {
return attachment.getThumbnailUri();
}
@NonNull
public Optional<String> getBody() {
String attachmentString = context.getString(R.string.attachment);
if (MediaUtil.isAudio(attachment)) {
// A missing file name is the legacy way to determine if an audio attachment is
// a voice note vs. other arbitrary audio attachments.
if (attachment.isVoiceNote() || attachment.getFileName() == null ||
attachment.getFileName().isEmpty()) {
attachmentString = context.getString(R.string.attachment_type_voice_message);
return Optional.fromNullable("🎤 " + attachmentString);
}
}
return Optional.fromNullable(emojiForMimeType() + attachmentString);
}
private String emojiForMimeType() {
if (MediaUtil.isImage(attachment)) {
return "📷 ";
} else if (MediaUtil.isVideo(attachment)) {
return "🎥 ";
} else if (MediaUtil.isAudio(attachment)) {
return "🎧 ";
} else if (MediaUtil.isFile(attachment)) {
return "📎 ";
} else {
return "🎡 ";
}
}
@NonNull
public Optional<String> getCaption() {
return Optional.fromNullable(attachment.getCaption());
}
@NonNull
public Optional<String> getFileName() {
return Optional.fromNullable(attachment.getFileName());
}
@Nullable
public String getFastPreflightId() {
return attachment.getFastPreflightId();
}
public long getFileSize() {
return attachment.getSize();
}
public boolean hasImage() {
return false;
}
public boolean hasVideo() {
return false;
}
public boolean hasAudio() {
return false;
}
public boolean hasDocument() {
return false;
}
public @NonNull String getContentDescription() { return ""; }
public @NonNull Attachment asAttachment() {
return attachment;
}
public boolean isInProgress() {
return attachment.isInProgress();
}
public boolean isPendingDownload() {
return getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED ||
getTransferState() == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING;
}
public int getTransferState() {
return attachment.getTransferState();
}
public @DrawableRes int getPlaceholderRes(Theme theme) {
throw new AssertionError("getPlaceholderRes() called for non-drawable slide");
}
public boolean hasPlaceholder() {
return false;
}
public boolean hasPlayOverlay() {
return false;
}
protected static Attachment constructAttachmentFromUri(@NonNull Context context,
@NonNull Uri uri,
@NonNull String defaultMime,
long size,
int width,
int height,
boolean hasThumbnail,
@Nullable String fileName,
@Nullable String caption,
boolean voiceNote,
boolean quote)
{
String resolvedType = Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime);
String fastPreflightId = String.valueOf(new SecureRandom().nextLong());
return new UriAttachment(uri,
hasThumbnail ? uri : null,
resolvedType,
AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED,
size,
width,
height,
fileName,
fastPreflightId,
voiceNote,
quote,
caption);
}
@Override
public boolean equals(Object other) {
if (other == null) return false;
if (!(other instanceof Slide)) return false;
Slide that = (Slide)other;
return Util.equals(this.getContentType(), that.getContentType()) &&
this.hasAudio() == that.hasAudio() &&
this.hasImage() == that.hasImage() &&
this.hasVideo() == that.hasVideo() &&
this.getTransferState() == that.getTransferState() &&
Util.equals(this.getUri(), that.getUri()) &&
Util.equals(this.getThumbnailUri(), that.getThumbnailUri());
}
@Override
public int hashCode() {
return Util.hashCode(getContentType(), hasAudio(), hasImage(),
hasVideo(), getUri(), getThumbnailUri(), getTransferState());
}
}

View File

@@ -0,0 +1,180 @@
/**
* Copyright (C) 2011 Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http:></http:>//www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.mms
import android.content.Context
import android.content.res.Resources
import android.net.Uri
import androidx.annotation.DrawableRes
import com.squareup.phrase.Phrase
import java.security.SecureRandom
import network.loki.messenger.R
import org.session.libsession.messaging.sending_receiving.attachments.Attachment
import org.session.libsession.messaging.sending_receiving.attachments.AttachmentTransferProgress
import org.session.libsession.messaging.sending_receiving.attachments.UriAttachment
import org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY
import org.session.libsession.utilities.Util.equals
import org.session.libsession.utilities.Util.hashCode
import org.session.libsignal.utilities.guava.Optional
import org.thoughtcrime.securesms.conversation.v2.Util
import org.thoughtcrime.securesms.util.MediaUtil
abstract class Slide(@JvmField protected val context: Context, protected val attachment: Attachment) {
val contentType: String
get() = attachment.contentType
val uri: Uri?
get() = attachment.dataUri
open val thumbnailUri: Uri?
get() = attachment.thumbnailUri
val body: Optional<String>
get() {
if (MediaUtil.isAudio(attachment)) {
// A missing file name is the legacy way to determine if an audio attachment is
// a voice note vs. other arbitrary audio attachments.
if (attachment.isVoiceNote || attachment.fileName.isNullOrEmpty()) {
val baseString = context.getString(R.string.attachment_type_voice_message)
val languageIsLTR = Util.usingLeftToRightLanguage(context)
val attachmentString = if (languageIsLTR) {
"🎙 $baseString"
} else {
"$baseString 🎙"
}
return Optional.fromNullable(attachmentString)
}
}
val txt = Phrase.from(context, R.string.attachmentsNotification)
.put(EMOJI_KEY, emojiForMimeType())
.format().toString()
return Optional.fromNullable(txt)
}
private fun emojiForMimeType(): String {
return if (MediaUtil.isGif(attachment)) {
"🎡"
} else if (MediaUtil.isImage(attachment)) {
"📷"
} else if (MediaUtil.isVideo(attachment)) {
"🎥"
} else if (MediaUtil.isAudio(attachment)) {
"🎧"
} else if (MediaUtil.isFile(attachment)) {
"📎"
} else {
// We don't provide emojis for other mime-types such as VCARD
""
}
}
val caption: Optional<String?>
get() = Optional.fromNullable(attachment.caption)
val fileName: Optional<String?>
get() = Optional.fromNullable(attachment.fileName)
val fastPreflightId: String?
get() = attachment.fastPreflightId
val fileSize: Long
get() = attachment.size
open fun hasImage(): Boolean { return false }
open fun hasVideo(): Boolean { return false }
open fun hasAudio(): Boolean { return false }
open fun hasDocument(): Boolean { return false }
open val contentDescription: String
get() = ""
fun asAttachment(): Attachment { return attachment }
val isInProgress: Boolean
get() = attachment.isInProgress
val isPendingDownload: Boolean
get() = transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_FAILED ||
transferState == AttachmentTransferProgress.TRANSFER_PROGRESS_PENDING
val transferState: Int
get() = attachment.transferState
@DrawableRes
open fun getPlaceholderRes(theme: Resources.Theme?): Int {
throw AssertionError("getPlaceholderRes() called for non-drawable slide")
}
open fun hasPlaceholder(): Boolean { return false }
open fun hasPlayOverlay(): Boolean { return false }
override fun equals(other: Any?): Boolean {
if (other == null) return false
if (other !is Slide) return false
return (equals(this.contentType, other.contentType) &&
hasAudio() == other.hasAudio() &&
hasImage() == other.hasImage() &&
hasVideo() == other.hasVideo()) &&
this.transferState == other.transferState &&
equals(this.uri, other.uri) &&
equals(this.thumbnailUri, other.thumbnailUri)
}
override fun hashCode(): Int {
return hashCode(contentType, hasAudio(), hasImage(), hasVideo(), uri, thumbnailUri, transferState)
}
companion object {
@JvmStatic
protected fun constructAttachmentFromUri(
context: Context,
uri: Uri,
defaultMime: String,
size: Long,
width: Int,
height: Int,
hasThumbnail: Boolean,
fileName: String?,
caption: String?,
voiceNote: Boolean,
quote: Boolean
): Attachment {
val resolvedType =
Optional.fromNullable(MediaUtil.getMimeType(context, uri)).or(defaultMime)
val fastPreflightId = SecureRandom().nextLong().toString()
return UriAttachment(
uri,
if (hasThumbnail) uri else null,
resolvedType!!,
AttachmentTransferProgress.TRANSFER_PROGRESS_STARTED,
size,
width,
height,
fileName,
fastPreflightId,
voiceNote,
quote,
caption
)
}
}
}

View File

@@ -24,6 +24,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.session.libsession.messaging.sending_receiving.attachments.Attachment;
import org.session.libsignal.utilities.Log;
import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.util.MediaUtil;
@@ -47,8 +48,7 @@ public class SlideDeck {
if (slide != null) slides.add(slide);
}
public SlideDeck() {
}
public SlideDeck() { }
public void clear() {
slides.clear();
@@ -65,7 +65,6 @@ public class SlideDeck {
body = slideBody.get();
}
}
return body;
}

View File

@@ -76,7 +76,7 @@ class BackgroundPollWorker(val context: Context, params: WorkerParameters) : Wor
}
override fun doWork(): Result {
if (TextSecurePreferences.getLocalNumber(context) == null || !TextSecurePreferences.hasSeenWelcomeScreen(context)) {
if (TextSecurePreferences.getLocalNumber(context) == null) {
Log.v(TAG, "User not registered yet.")
return Result.failure()
}

View File

@@ -29,6 +29,7 @@ import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.service.notification.StatusBarNotification;
import android.text.SpannableString;
import android.text.TextUtils;
import androidx.annotation.NonNull;
@@ -42,7 +43,7 @@ import com.goterl.lazysodium.utils.KeyPair;
import org.session.libsession.messaging.open_groups.OpenGroup;
import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier;
import org.session.libsession.messaging.utilities.SessionId;
import org.session.libsession.messaging.utilities.AccountId;
import org.session.libsession.messaging.utilities.SodiumUtilities;
import org.session.libsession.snode.SnodeAPI;
import org.session.libsession.utilities.Address;
@@ -145,9 +146,8 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
public void notifyMessagesPending(Context context) {
if (!TextSecurePreferences.isNotificationsEnabled(context)) {
return;
}
if (!TextSecurePreferences.isNotificationsEnabled(context)) { return; }
PendingMessageNotificationBuilder builder = new PendingMessageNotificationBuilder(context, TextSecurePreferences.getNotificationPrivacy(context));
ServiceUtil.getNotificationManager(context).notify(PENDING_MESSAGES_ID, builder.build());
@@ -185,9 +185,9 @@ public class DefaultMessageNotifier implements MessageNotifier {
for (StatusBarNotification notification : activeNotifications) {
boolean validNotification = false;
if (notification.getId() != SUMMARY_NOTIFICATION_ID &&
notification.getId() != KeyCachingService.SERVICE_RUNNING_ID &&
notification.getId() != FOREGROUND_ID &&
if (notification.getId() != SUMMARY_NOTIFICATION_ID &&
notification.getId() != KeyCachingService.SERVICE_RUNNING_ID &&
notification.getId() != FOREGROUND_ID &&
notification.getId() != PENDING_MESSAGES_ID)
{
for (NotificationItem item : notificationState.getNotifications()) {
@@ -197,9 +197,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
}
if (!validNotification) {
notifications.cancel(notification.getId());
}
if (!validNotification) { notifications.cancel(notification.getId()); }
}
}
} catch (Throwable e) {
@@ -231,7 +229,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
@Override
public void updateNotification(@NonNull Context context, long threadId, boolean signal)
{
boolean isVisible = visibleThread == threadId;
boolean isVisible = visibleThread == threadId;
ThreadDatabase threads = DatabaseComponent.get(context).threadDatabase();
Recipient recipient = threads.getRecipientForThreadId(threadId);
@@ -271,7 +269,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
try {
telcoCursor = DatabaseComponent.get(context).mmsSmsDatabase().getUnread(); // TODO: add a notification specific lighter query here
if ((telcoCursor == null || telcoCursor.isAfterLast()) || !TextSecurePreferences.hasSeenWelcomeScreen(context))
if ((telcoCursor == null || telcoCursor.isAfterLast()) || TextSecurePreferences.getLocalNumber(context) == null)
{
updateBadge(context, 0);
cancelActiveNotifications(context);
@@ -348,14 +346,19 @@ public class DefaultMessageNotifier implements MessageNotifier {
builder.setThread(notifications.get(0).getRecipient());
builder.setMessageCount(notificationState.getMessageCount());
// TODO: Removing highlighting mentions in the notification because this context is the libsession one which
// TODO: doesn't have access to the `R.attr.message_sent_text_color` and `R.attr.message_received_text_color`
// TODO: attributes to perform the colour lookup. Also, it makes little sense to highlight the mentions using
// TODO: the app theme as it may result in insufficient contrast with the notification background which will
// TODO: be using the SYSTEM theme.
builder.setPrimaryMessageBody(recipient, notifications.get(0).getIndividualRecipient(),
//MentionUtilities.highlightMentions(text == null ? "" : text, notifications.get(0).getThreadId(), context), // Removing hightlighting mentions -ACL
text == null ? "" : text,
CharSequence builderCS = text == null ? "" : text;
SpannableString ss = MentionUtilities.highlightMentions(
builderCS,
false,
false,
true,
bundled ? notifications.get(0).getThreadId() : 0,
context
);
builder.setPrimaryMessageBody(recipient,
notifications.get(0).getIndividualRecipient(),
ss,
notifications.get(0).getSlideDeck());
builder.setContentIntent(notifications.get(0).getPendingIntent(context));
@@ -505,24 +508,39 @@ public class DefaultMessageNotifier implements MessageNotifier {
continue;
}
}
// If this is a message request from an unknown user..
if (messageRequest) {
body = SpanUtil.italic(context.getString(R.string.message_requests_notification));
// If we received some manner of notification but Session is locked..
} else if (KeyCachingService.isLocked(context)) {
body = SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message));
// ----- All further cases assume we know the contact and that Session isn't locked -----
// If this is a notification about a multimedia message from a contact we know about..
} else if (record.isMms() && !((MmsMessageRecord) record).getSharedContacts().isEmpty()) {
Contact contact = ((MmsMessageRecord) record).getSharedContacts().get(0);
body = ContactUtil.getStringSummary(context, contact);
// If this is a notification about a multimedia message which contains no text but DOES contain a slide deck with at least one slide..
} else if (record.isMms() && TextUtils.isEmpty(body) && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) {
slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck();
body = SpanUtil.italic(slideDeck.getBody());
// If this is a notification about a multimedia message, but it's not ITSELF a multimedia notification AND it contains a slide deck with at least one slide..
} else if (record.isMms() && !record.isMmsNotification() && !((MmsMessageRecord) record).getSlideDeck().getSlides().isEmpty()) {
slideDeck = ((MediaMmsMessageRecord)record).getSlideDeck();
String message = slideDeck.getBody() + ": " + record.getBody();
int italicLength = message.length() - body.length();
body = SpanUtil.italic(message, italicLength);
// If this is a notification about an invitation to a community..
} else if (record.isOpenGroupInvitation()) {
body = SpanUtil.italic(context.getString(R.string.ThreadRecord_open_group_invitation));
}
String userPublicKey = TextSecurePreferences.getLocalNumber(context);
String blindedPublicKey = cache.get(threadId);
if (blindedPublicKey == null) {
@@ -576,7 +594,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
if (openGroup != null && edKeyPair != null) {
KeyPair blindedKeyPair = SodiumUtilities.blindedKeyPair(openGroup.getPublicKey(), edKeyPair);
if (blindedKeyPair != null) {
return new SessionId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString();
return new AccountId(IdPrefix.BLINDED, blindedKeyPair.getPublicKey().getAsBytes()).getHexString();
}
}
return null;

View File

@@ -1,11 +1,9 @@
package org.thoughtcrime.securesms.notifications;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.AsyncTask;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.dependencies.DatabaseComponent;

View File

@@ -118,11 +118,11 @@ public class MultipleRecipientNotificationBuilder extends AbstractNotificationBu
*/
private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) {
SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase();
String sessionID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithSessionID(sessionID);
if (contact == null) { return sessionID; }
String accountID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithAccountID(accountID);
if (contact == null) { return accountID; }
String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR);
if (displayName == null) { return sessionID; }
if (displayName == null) { return accountID; }
return displayName;
}
}

View File

@@ -10,6 +10,7 @@ import kotlinx.serialization.json.decodeFromStream
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.map
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import org.session.libsession.messaging.sending_receiving.notifications.Response
@@ -99,7 +100,7 @@ class PushRegistryV2 @Inject constructor(private val pushReceiver: PushReceiver)
private inline fun <reified T: Response> getResponseBody(path: String, requestParameters: String): Promise<T, Exception> {
val server = Server.LATEST
val url = "${server.url}/$path"
val body = RequestBody.create(MediaType.get("application/json"), requestParameters)
val body = RequestBody.create("application/json".toMediaType(), requestParameters)
val request = Request.Builder().url(url).post(body).build()
return OnionRequestAPI.sendOnionRequest(

View File

@@ -339,11 +339,11 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil
*/
private String getGroupDisplayName(Recipient recipient, boolean openGroupRecipient) {
SessionContactDatabase contactDB = DatabaseComponent.get(context).sessionContactDatabase();
String sessionID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithSessionID(sessionID);
if (contact == null) { return sessionID; }
String accountID = recipient.getAddress().serialize();
Contact contact = contactDB.getContactWithAccountID(accountID);
if (contact == null) { return accountID; }
String displayName = contact.displayName(openGroupRecipient ? Contact.ContactContext.OPEN_GROUP : Contact.ContactContext.REGULAR);
if (displayName == null) { return sessionID; }
if (displayName == null) { return accountID; }
return displayName;
}
}

View File

@@ -1,57 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.Intent
import android.os.Bundle
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView.OnEditorActionListener
import android.widget.Toast
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityDisplayNameBinding
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import org.session.libsession.utilities.TextSecurePreferences
class DisplayNameActivity : BaseActionBarActivity() {
private lateinit var binding: ActivityDisplayNameBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
binding = ActivityDisplayNameBinding.inflate(layoutInflater)
setContentView(binding.root)
with(binding) {
displayNameEditText.imeOptions = displayNameEditText.imeOptions or 16777216 // Always use incognito keyboard
displayNameEditText.setOnEditorActionListener(
OnEditorActionListener { _, actionID, event ->
if (actionID == EditorInfo.IME_ACTION_SEARCH ||
actionID == EditorInfo.IME_ACTION_DONE ||
(event.action == KeyEvent.ACTION_DOWN &&
event.keyCode == KeyEvent.KEYCODE_ENTER)) {
register()
return@OnEditorActionListener true
}
false
})
registerButton.setOnClickListener { register() }
}
}
private fun register() {
val displayName = binding.displayNameEditText.text.toString().trim()
if (displayName.isEmpty()) {
return Toast.makeText(this, R.string.activity_display_name_display_name_missing_error, Toast.LENGTH_SHORT).show()
}
if (displayName.toByteArray().size > ProfileManagerProtocol.Companion.NAME_PADDED_LENGTH) {
return Toast.makeText(this, R.string.activity_display_name_display_name_too_long_error, Toast.LENGTH_SHORT).show()
}
val inputMethodManager = getSystemService(INPUT_METHOD_SERVICE) as InputMethodManager
inputMethodManager.hideSoftInputFromWindow(binding.displayNameEditText.windowToken, 0)
TextSecurePreferences.setProfileName(this, displayName)
val intent = Intent(this, PNModeActivity::class.java)
push(intent)
}
}

View File

@@ -1,79 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.animation.FloatEvaluator
import android.animation.ValueAnimator
import android.content.Context
import android.os.Handler
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.ScrollView
import network.loki.messenger.R
import network.loki.messenger.databinding.ViewFakeChatBinding
import org.thoughtcrime.securesms.util.disableClipping
class FakeChatView : ScrollView {
private lateinit var binding: ViewFakeChatBinding
// region Settings
private val spacing = context.resources.getDimension(R.dimen.medium_spacing)
private val startDelay: Long = 1000
private val delayBetweenMessages: Long = 1500
private val animationDuration: Long = 400
// endregion
// region Lifecycle
constructor(context: Context) : super(context) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
setUpViewHierarchy()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) {
setUpViewHierarchy()
}
private fun setUpViewHierarchy() {
binding = ViewFakeChatBinding.inflate(LayoutInflater.from(context), this, true)
binding.root.disableClipping()
isVerticalScrollBarEnabled = false
}
// endregion
// region Animation
fun startAnimating() {
listOf( binding.bubble1, binding.bubble2, binding.bubble3, binding.bubble4, binding.bubble5 ).forEach { it.alpha = 0.0f }
fun show(bubble: View) {
val animation = ValueAnimator.ofObject(FloatEvaluator(), 0.0f, 1.0f)
animation.duration = animationDuration
animation.addUpdateListener { animator ->
bubble.alpha = animator.animatedValue as Float
}
animation.start()
}
Handler().postDelayed({
show(binding.bubble1)
Handler().postDelayed({
show(binding.bubble2)
Handler().postDelayed({
show(binding.bubble3)
smoothScrollTo(0, (binding.bubble1.height + spacing).toInt())
Handler().postDelayed({
show(binding.bubble4)
smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt())
Handler().postDelayed({
show(binding.bubble5)
smoothScrollTo(0, (binding.bubble1.height + spacing).toInt() + (binding.bubble2.height + spacing).toInt() + (binding.bubble3.height + spacing).toInt())
}, delayBetweenMessages)
}, delayBetweenMessages)
}, delayBetweenMessages)
}, delayBetweenMessages)
}, startDelay)
}
// endregion
}

View File

@@ -1,230 +0,0 @@
package org.thoughtcrime.securesms.onboarding
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentPagerAdapter
import androidx.lifecycle.lifecycleScope
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityLinkDeviceBinding
import network.loki.messenger.databinding.FragmentRecoveryPhraseBinding
import org.session.libsession.snode.SnodeModule
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.crypto.MnemonicCodec
import org.session.libsignal.database.LokiAPIDatabaseProtocol
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.KeyHelper
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.hexEncodedPublicKey
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.crypto.KeyPairUtilities
import org.thoughtcrime.securesms.crypto.MnemonicUtilities
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragment
import org.thoughtcrime.securesms.util.ScanQRCodeWrapperFragmentDelegate
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.setUpActionBarSessionLogo
import javax.inject.Inject
@AndroidEntryPoint
class LinkDeviceActivity : BaseActionBarActivity(), ScanQRCodeWrapperFragmentDelegate {
@Inject
lateinit var configFactory: ConfigFactory
private lateinit var binding: ActivityLinkDeviceBinding
internal val database: LokiAPIDatabaseProtocol
get() = SnodeModule.shared.storage
private val adapter = LinkDeviceActivityAdapter(this)
private var restoreJob: Job? = null
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
if (restoreJob?.isActive == true) return // Don't allow going back with a pending job
super.onBackPressed()
}
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setUpActionBarSessionLogo()
TextSecurePreferences.apply {
setHasViewedSeed(this@LinkDeviceActivity, true)
setConfigurationMessageSynced(this@LinkDeviceActivity, false)
setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis())
setLastProfileUpdateTime(this@LinkDeviceActivity, 0)
}
binding = ActivityLinkDeviceBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.viewPager.adapter = adapter
binding.tabLayout.setupWithViewPager(binding.viewPager)
}
// endregion
// region Interaction
override fun handleQRCodeScanned(mnemonic: String) {
try {
val seed = Hex.fromStringCondensed(mnemonic)
continueWithSeed(seed)
} catch (e: Exception) {
Log.e("Loki","Error getting seed from QR code", e)
Toast.makeText(this, "An error occurred.", Toast.LENGTH_LONG).show()
}
}
fun continueWithMnemonic(mnemonic: String) {
val loadFileContents: (String) -> String = { fileName ->
MnemonicUtilities.loadFileContents(this, fileName)
}
try {
val hexEncodedSeed = MnemonicCodec(loadFileContents).decode(mnemonic)
val seed = Hex.fromStringCondensed(hexEncodedSeed)
continueWithSeed(seed)
} catch (error: Exception) {
val message = if (error is MnemonicCodec.DecodingError) {
error.description
} else {
"An error occurred."
}
Toast.makeText(this, message, Toast.LENGTH_LONG).show()
}
}
private fun continueWithSeed(seed: ByteArray) {
// only have one sync job running at a time (prevent QR from trying to spawn a new job)
if (restoreJob?.isActive == true) return
restoreJob = lifecycleScope.launch {
// This is here to resolve a case where the app restarts before a user completes onboarding
// which can result in an invalid database state
database.clearAllLastMessageHashes()
database.clearReceivedMessageHashValues()
// RestoreActivity handles seed this way
val keyPairGenerationResult = KeyPairUtilities.generate(seed)
val x25519KeyPair = keyPairGenerationResult.x25519KeyPair
KeyPairUtilities.store(this@LinkDeviceActivity, seed, keyPairGenerationResult.ed25519KeyPair, x25519KeyPair)
configFactory.keyPairChanged()
val userHexEncodedPublicKey = x25519KeyPair.hexEncodedPublicKey
val registrationID = KeyHelper.generateRegistrationId(false)
TextSecurePreferences.setLocalRegistrationId(this@LinkDeviceActivity, registrationID)
TextSecurePreferences.setLocalNumber(this@LinkDeviceActivity, userHexEncodedPublicKey)
TextSecurePreferences.setRestorationTime(this@LinkDeviceActivity, System.currentTimeMillis())
TextSecurePreferences.setHasViewedSeed(this@LinkDeviceActivity, true)
binding.loader.isVisible = true
val snackBar = Snackbar.make(binding.containerLayout, R.string.activity_link_device_skip_prompt,Snackbar.LENGTH_INDEFINITE)
.setAction(R.string.registration_activity__skip) { register(true) }
val skipJob = launch {
delay(15_000L)
snackBar.show()
}
// start polling and wait for updated message
ApplicationContext.getInstance(this@LinkDeviceActivity).apply {
startPollingIfNeeded()
}
TextSecurePreferences.events.filter { it == TextSecurePreferences.CONFIGURATION_SYNCED }.collect {
// handle we've synced
snackBar.dismiss()
skipJob.cancel()
register(false)
}
binding.loader.isVisible = false
}
}
private fun register(skipped: Boolean) {
restoreJob?.cancel()
binding.loader.isVisible = false
TextSecurePreferences.setLastConfigurationSyncTime(this, System.currentTimeMillis())
val intent = Intent(this@LinkDeviceActivity, if (skipped) DisplayNameActivity::class.java else PNModeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
push(intent)
}
// endregion
}
// region Adapter
private class LinkDeviceActivityAdapter(private val activity: LinkDeviceActivity) : FragmentPagerAdapter(activity.supportFragmentManager) {
val recoveryPhraseFragment = RecoveryPhraseFragment()
override fun getCount(): Int {
return 2
}
override fun getItem(index: Int): Fragment {
return when (index) {
0 -> recoveryPhraseFragment
1 -> {
val result = ScanQRCodeWrapperFragment()
result.delegate = activity
result.message = activity.getString(R.string.activity_link_device_qr_message)
result
}
else -> throw IllegalStateException()
}
}
override fun getPageTitle(index: Int): CharSequence {
return when (index) {
0 -> activity.getString(R.string.activity_link_device_recovery_phrase)
1 -> activity.getString(R.string.activity_link_device_scan_qr_code)
else -> throw IllegalStateException()
}
}
}
// endregion
// region Recovery Phrase Fragment
class RecoveryPhraseFragment : Fragment() {
private lateinit var binding: FragmentRecoveryPhraseBinding
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentRecoveryPhraseBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
with(binding) {
mnemonicEditText.imeOptions = EditorInfo.IME_ACTION_DONE or 16777216 // Always use incognito keyboard
mnemonicEditText.setRawInputType(InputType.TYPE_CLASS_TEXT)
mnemonicEditText.setOnEditorActionListener { v, actionID, _ ->
if (actionID == EditorInfo.IME_ACTION_DONE) {
val imm = v.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(v.windowToken, 0)
handleContinueButtonTapped()
true
} else {
false
}
}
continueButton.setOnClickListener { handleContinueButtonTapped() }
}
}
private fun handleContinueButtonTapped() {
val mnemonic = binding.mnemonicEditText.text?.trim().toString()
(requireActivity() as LinkDeviceActivity).continueWithMnemonic(mnemonic)
}
}
// endregion

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