mirror of
https://github.com/oxen-io/session-android.git
synced 2025-12-29 05:16:49 +00:00
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
@@ -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() {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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?) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>
|
||||
) {
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.conversation.start.newmessage
|
||||
|
||||
internal interface Callbacks {
|
||||
fun onChange(value: String) {}
|
||||
fun onContinue() {}
|
||||
fun onScanQrCode(value: String) {}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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?,
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 }) }
|
||||
|
||||
@@ -35,6 +35,5 @@ public class AndroidLogger extends Log.Logger {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void blockUntilAllWritesFinished() {
|
||||
}
|
||||
public void blockUntilAllWritesFinished() { }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
180
app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt
Normal file
180
app/src/main/java/org/thoughtcrime/securesms/mms/Slide.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user