Merge branch 'dev' into just-prefs

This commit is contained in:
bemusementpark 2024-07-18 12:39:49 +09:30
commit 268644edd2
240 changed files with 495468 additions and 297714 deletions

View File

@ -31,8 +31,8 @@ configurations.all {
exclude module: "commons-logging"
}
def canonicalVersionCode = 373
def canonicalVersionName = "1.18.4"
def canonicalVersionCode = 374
def canonicalVersionName = "1.18.5"
def postFixSize = 10
def abiPostFix = ['armeabi-v7a' : 1,
@ -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"

View File

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

View File

@ -125,7 +125,7 @@
<activity
android:name="org.thoughtcrime.securesms.home.HomeActivity"
android:screenOrientation="portrait"
android:launchMode="singleTask"
android:launchMode="standard"
android:theme="@style/Theme.Session.DayNight.NoActionBar" />
<activity
android:name="org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity"

File diff suppressed because it is too large Load Diff

View File

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

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

View File

@ -154,8 +154,6 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
private volatile boolean isAppVisible;
public boolean newAccount = false;
public static ApplicationContext getInstance(Context context) {
return (ApplicationContext) context.getApplicationContext();
}

View File

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

View File

@ -5,11 +5,17 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Outline
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorManager
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.view.MenuItem
import android.view.OrientationEventListener
import android.view.View
import android.view.ViewOutlineProvider
import android.view.WindowManager
import androidx.activity.viewModels
import androidx.core.content.ContextCompat
@ -21,13 +27,14 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import android.provider.Settings
import kotlinx.coroutines.launch
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityWebrtcBinding
import org.apache.commons.lang3.time.DurationFormatUtils
import org.session.libsession.avatars.ProfileContactPhoto
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.truncateIdForDisplay
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.dependencies.DatabaseComponent
@ -43,8 +50,11 @@ 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 javax.inject.Inject
import kotlin.math.asin
@AndroidEntryPoint
class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
@ -60,6 +70,8 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
private const val CALL_DURATION_FORMAT = "HH:mm:ss"
}
@Inject lateinit var prefs: TextSecurePreferences
private val viewModel by viewModels<CallViewModel>()
private val glide by lazy { GlideApp.with(this) }
private lateinit var binding: ActivityWebrtcBinding
@ -71,16 +83,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 +111,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 +138,10 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(false)
}
binding.floatingRendererContainer.setOnClickListener {
viewModel.swapVideos()
}
binding.microphoneButton.setOnClickListener {
val audioEnabledIntent =
WebRtcCallService.microphoneIntent(this, !viewModel.microphoneEnabled)
@ -174,7 +180,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 +197,53 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
onBackPressed()
}
lifecycleScope.launch {
orientationManager.orientation.collect { orientation ->
viewModel.deviceOrientation = orientation
updateControlsRotation()
}
}
clipFloatingInsets()
// set up the user avatar
prefs.getLocalNumber()?.let{
binding.userAvatar.apply {
publicKey = it
displayName = prefs.getProfileName() ?: truncateIdForDisplay(it)
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 +251,8 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
hangupReceiver?.let { receiver ->
LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver)
}
rotationListener.disable()
orientationManager.destroy()
}
private fun answerCall() {
@ -214,15 +260,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 +344,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,39 +386,65 @@ 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().getContactWithAccountID(publicKey)
@ -388,7 +454,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() {
override fun onStop() {
super.onStop()
uiJob?.cancel()
binding.remoteRenderer.removeAllViews()
binding.localRenderer.removeAllViews()
binding.fullscreenRenderer.removeAllViews()
binding.floatingRenderer.removeAllViews()
}
}

View File

@ -18,6 +18,7 @@ import org.session.libsession.utilities.Address
import org.session.libsession.utilities.GroupUtil
import org.session.libsession.utilities.TextSecurePreferences
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
@ -25,6 +26,8 @@ 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 = TextSecurePreferences(context)
@ -33,7 +36,6 @@ class ProfilePictureView @JvmOverloads constructor(
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)
@ -91,31 +93,27 @@ class ProfilePictureView @JvmOverloads constructor(
}
fun update() {
val publicKey = publicKey ?: return
val publicKey = publicKey ?: return Log.w(TAG, "Could not find public key to update profile picture")
val additionalPublicKey = additionalPublicKey
// if we have a multi avatar setup
if (additionalPublicKey != null) {
setProfilePictureIfNeeded(binding.doubleModeImageView1, publicKey, displayName)
setProfilePictureIfNeeded(binding.doubleModeImageView2, additionalPublicKey, additionalDisplayName)
binding.doubleModeImageViewContainer.visibility = View.VISIBLE
} else {
// clear single image
glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE
} else { // single image mode
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE
// clear multi image
glide.clear(binding.doubleModeImageView1)
glide.clear(binding.doubleModeImageView2)
binding.doubleModeImageViewContainer.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && !isLarge) {
setProfilePictureIfNeeded(binding.singleModeImageView, publicKey, displayName)
binding.singleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.singleModeImageView)
binding.singleModeImageView.visibility = View.INVISIBLE
}
if (additionalPublicKey == null && isLarge) {
setProfilePictureIfNeeded(binding.largeSingleModeImageView, publicKey, displayName)
binding.largeSingleModeImageView.visibility = View.VISIBLE
} else {
glide.clear(binding.largeSingleModeImageView)
binding.largeSingleModeImageView.visibility = View.INVISIBLE
}
}
private fun setProfilePictureIfNeeded(imageView: ImageView, publicKey: String, displayName: String?) {

View File

@ -49,17 +49,20 @@ internal fun StartConversationScreen(
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)

View File

@ -85,8 +85,8 @@ private fun EnterAccountId(
SessionOutlinedTextField(
text = state.newMessageIdOrOns,
modifier = Modifier
.padding(horizontal = LocalDimensions.current.smallMargin)
.contentDescription("Session id input box"),
.padding(horizontal = LocalDimensions.current.smallMargin),
contentDescription = "Session id input box",
placeholder = stringResource(R.string.accountIdOrOnsEnter),
onChange = callbacks::onChange,
onContinue = callbacks::onContinue,

View File

@ -45,7 +45,7 @@ class NewMessageFragment : Fragment() {
viewModel,
onClose = { delegate.onDialogClosePressed() },
onBack = { delegate.onDialogBackPressed() },
onHelp = { requireContext().openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Session-ID-usernames-work") }
onHelp = { requireContext().openUrl("https://sessionapp.zendesk.com/hc/en-us/articles/4439132747033-How-do-Account-ID-usernames-work") }
)
}

View File

@ -239,12 +239,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
intent.getParcelableExtra<Address>(ADDRESS)?.let { it ->
threadId = threadDb.getThreadIdIfExistsFor(it.serialize())
if (threadId == -1L) {
val sessionId = AccountId(it.serialize())
val accountId = AccountId(it.serialize())
val openGroup = lokiThreadDb.getOpenGroupChat(intent.getLongExtra(FROM_GROUP_THREAD_ID, -1))
val address = if (sessionId.prefix == IdPrefix.BLINDED && openGroup != null) {
storage.getOrCreateBlindedIdMapping(sessionId.hexString, openGroup.server, openGroup.publicKey).sessionId?.let {
val address = if (accountId.prefix == IdPrefix.BLINDED && openGroup != null) {
storage.getOrCreateBlindedIdMapping(accountId.hexString, openGroup.server, openGroup.publicKey).accountId?.let {
fromSerialized(it)
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, sessionId)
} ?: GroupUtil.getEncodedOpenGroupInboxID(openGroup, accountId)
} else {
it
}
@ -1131,8 +1131,8 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe
}
}
override fun copyAccountID(sessionId: String) {
val clip = ClipData.newPlainText("Account ID", sessionId)
override fun copyAccountID(accountId: String) {
val clip = ClipData.newPlainText("Account ID", accountId)
val manager = getSystemService(PassphraseRequiredActionBarActivity.CLIPBOARD_SERVICE) as ClipboardManager
manager.setPrimaryClip(clip)
Toast.makeText(this, R.string.copied_to_clipboard, Toast.LENGTH_SHORT).show()

View File

@ -2,19 +2,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.EditText
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.core.view.isGone
@ -33,11 +30,8 @@ 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.SimpleTextWatcher
import org.thoughtcrime.securesms.util.addTextChangedListener
import org.thoughtcrime.securesms.util.contains
import org.thoughtcrime.securesms.util.toDp
import org.thoughtcrime.securesms.util.toPx
// 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.
@ -48,16 +42,24 @@ enum class VoiceRecorderState {
ShuttingDownAfterRecord
}
class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, LinkPreviewDraftViewDelegate,
@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
@ -70,7 +72,7 @@ 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) }
// Keep track of when the user pressed the record voice message button, the duration that
@ -79,21 +81,11 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
var voiceMessageDurationMS = 0L
var voiceRecorderState = VoiceRecorderState.Idle
val attachmentButtonsContainerHeight: Int
get() = binding.attachmentsButtonContainer.height
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)}
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)} }
// 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() }
@SuppressLint("ClickableViewAccessibility")
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)
@ -112,6 +104,7 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
// `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
@ -162,12 +155,11 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
binding.inputBarEditText.setOnEditorActionListener(this)
if (context.prefs.isEnterSendsEnabled()) {
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 (context.prefs.isIncognitoKeyboardEnabled()) 16777216 else 0
@ -184,9 +176,6 @@ 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
@ -288,12 +277,9 @@ class InputBar : RelativeLayout, InputBarEditTextDelegate, QuoteViewDelegate, Li
fun setInputBarEditableFactory(factory: Editable.Factory) {
binding.inputBarEditText.setEditableFactory(factory)
}
// endregion
}
interface InputBarDelegate {
fun inputBarEditTextContentChanged(newContent: CharSequence)
fun toggleAttachmentOptions()
fun showVoiceMessageUI()
@ -303,4 +289,4 @@ interface InputBarDelegate {
fun onMicrophoneButtonUp(event: MotionEvent)
fun sendMessage()
fun commitInputContent(contentUri: Uri)
}
}

View File

@ -57,7 +57,7 @@ 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_account_id, menu)
}
@ -325,7 +325,7 @@ object ConversationMenuHelper {
interface ConversationMenuListener {
fun block(deleteThread: Boolean = false)
fun unblock()
fun copyAccountID(sessionId: String)
fun copyAccountID(accountId: String)
fun copyOpenGroupUrl(thread: Recipient)
fun showDisappearingMessages(thread: Recipient)
}

View File

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

View File

@ -1,29 +1,24 @@
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.MessagingModuleConfiguration
import org.session.libsession.messaging.contacts.Contact
import org.session.libsession.messaging.open_groups.OpenGroup
import org.session.libsession.messaging.utilities.SodiumUtilities
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsession.utilities.ThemeUtil
import org.session.libsession.utilities.getColorFromAttr
import org.session.libsession.utilities.prefs
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 {
@ -69,7 +64,7 @@ object MentionUtilities {
} else {
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)
contact?.displayName(context) ?: truncateIdForDisplay(publicKey)
}
if (userDisplayName != null) {
val mention = "@$userDisplayName"
@ -162,7 +157,7 @@ object MentionUtilities {
}
private fun isYou(mentionedPublicKey: String, userPublicKey: String, openGroup: OpenGroup?): Boolean {
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.sessionId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false
val isUserBlindedPublicKey = openGroup?.let { SodiumUtilities.accountId(userPublicKey, mentionedPublicKey, it.publicKey) } ?: false
return mentionedPublicKey.equals(userPublicKey, ignoreCase = true) || isUserBlindedPublicKey
}
}

View File

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

View File

@ -1243,73 +1243,49 @@ 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 (context.prefs.isReadReceiptsEnabled()) 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 (context.prefs.isReadReceiptsEnabled()) 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 (!context.prefs.isReadReceiptsEnabled()) {
readReceiptCount = 0
}

View File

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

View File

@ -15,7 +15,7 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
companion object {
private const val sessionContactTable = "session_contact_database"
const val accountID = "account_id"
const val accountID = "session_id"
const val name = "name"
const val nickname = "nickname"
const val profilePictureURL = "profile_picture_url"
@ -42,12 +42,12 @@ class SessionContactDatabase(context: Context, helper: SQLCipherOpenHelper) : Da
}
}
fun getContacts(sessionIDs: Collection<String>): List<Contact> {
fun getContacts(accountIDs: Collection<String>): List<Contact> {
val database = databaseHelper.readableDatabase
return database.getAll(
sessionContactTable,
"$accountID IN (SELECT value FROM json_each(?))",
arrayOf(JSONArray(sessionIDs).toString())
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 = AccountId(contact.accountID)
sessionId.prefix == IdPrefix.STANDARD
contact.accountID.let(::AccountId).prefix == IdPrefix.STANDARD
}.toSet()
}

View File

@ -2,14 +2,17 @@ package org.thoughtcrime.securesms.database
import android.content.Context
import android.net.Uri
import java.security.MessageDigest
import network.loki.messenger.libsession_util.ConfigBase
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_PINNED
import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE
import network.loki.messenger.libsession_util.Contacts
import network.loki.messenger.libsession_util.ConversationVolatileConfig
import network.loki.messenger.libsession_util.UserGroupsConfig
import network.loki.messenger.libsession_util.UserProfile
import network.loki.messenger.libsession_util.util.BaseCommunityInfo
import network.loki.messenger.libsession_util.util.Contact as LibSessionContact
import network.loki.messenger.libsession_util.util.Conversation
import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.libsession_util.util.GroupInfo
@ -94,8 +97,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"
@ -113,12 +114,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)
@ -134,11 +135,11 @@ open class Storage(
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())
@ -151,9 +152,9 @@ 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")
@ -267,10 +268,8 @@ open class Storage(
}
// otherwise recipient is one to one
recipient.isContactRecipient -> {
// don't process non-standard session IDs though
val sessionId = AccountId(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()}")
@ -301,8 +300,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 -> {
@ -474,7 +473,8 @@ 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
@ -485,13 +485,14 @@ open class Storage(
if (it != name) userProfile.setName(it)
}
// update pfp
// Update profile picture
if (userPic == UserPic.DEFAULT) {
clearUserPic()
} else if (userPic.key.isNotEmpty() && userPic.url.isNotEmpty()
&& context.prefs.getProfilePictureURL() != userPic.url) {
setUserProfilePicture(userPic.url, userPic.key)
}
if (userProfile.getNtsPriority() == PRIORITY_HIDDEN) {
// delete nts thread if needed
val ourThread = getThreadId(recipient) ?: return
@ -519,12 +520,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
context.prefs.setProfileKey(null)
ProfileKeyUtil.setEncodedProfileKey(context, null)
recipientDatabase.setProfileAvatar(recipient, null)
@ -533,14 +535,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)
}
@ -571,7 +572,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
}
@ -605,8 +606,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) {
@ -625,19 +626,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(
@ -938,10 +939,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(),
@ -975,7 +976,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)
)
@ -1209,7 +1210,7 @@ open class Storage(
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = AccountId(contact.id)
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.sessionId != null }
id.prefix?.isBlinded() == false || mappingDb.getBlindedIdMapping(contact.id).none { it.accountId != null }
}
val profileManager = SSKEnvironment.shared.profileManager
moreContacts.forEach { contact ->
@ -1262,7 +1263,7 @@ open class Storage(
val mappingDb = DatabaseComponent.get(context).blindedIdMappingDatabase()
val moreContacts = contacts.filter { contact ->
val id = AccountId(contact.publicKey)
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.sessionId != null }
id.prefix != IdPrefix.BLINDED || mappingDb.getBlindedIdMapping(contact.publicKey).none { it.accountId != null }
}
for (contact in moreContacts) {
val address = fromSerialized(contact.publicKey)
@ -1329,25 +1330,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)
}
@ -1505,10 +1506,10 @@ 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)
@ -1614,20 +1615,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 = AccountId(contact.accountID)
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
}

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.Resources
import android.graphics.Typeface
import android.graphics.drawable.ColorDrawable
import android.text.SpannableString
import android.text.TextUtils
import android.util.AttributeSet
import android.util.TypedValue
@ -22,7 +21,6 @@ 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 +48,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(
@ -141,11 +139,10 @@ class ConversationView : LinearLayout {
else -> recipient.toShortString() // Internally uses the Contact API
}
private fun ThreadRecord.getSnippet(): CharSequence =
concatSnippet(getSnippetPrefix(), getDisplayBody(context))
private fun concatSnippet(prefix: CharSequence?, body: CharSequence): CharSequence =
prefix?.let { TextUtils.concat(it, ": ", body) } ?: body
private fun ThreadRecord.getSnippet(): CharSequence = listOfNotNull(
getSnippetPrefix(),
getDisplayBody(context)
).joinToString(": ")
private fun ThreadRecord.getSnippetPrefix(): CharSequence? = when {
recipient.isLocalNumber || lastMessage?.isControlMessage == true -> null

View File

@ -21,6 +21,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
@ -58,6 +59,7 @@ 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
@ -84,6 +86,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
GlobalSearchInputLayout.GlobalSearchInputLayoutListener {
companion object {
const val NEW_ACCOUNT = "HomeActivity_NEW_ACCOUNT"
const val FROM_ONBOARDING = "HomeActivity_FROM_ONBOARDING"
}
@ -134,9 +137,14 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
private val isNewAccount: Boolean get() = intent.getBooleanExtra(FROM_ONBOARDING, false)
// region Lifecycle
override fun onCreate(savedInstanceState: Bundle?, isReady: Boolean) {
super.onCreate(savedInstanceState, isReady)
if (!isTaskRoot) { finish(); return }
// Set content view
binding = ActivityHomeBinding.inflate(layoutInflater)
setContentView(binding.root)
@ -171,7 +179,7 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
// Set up empty state view
binding.emptyStateContainer.setThemedContent {
EmptyView(ApplicationContext.getInstance(this).newAccount)
EmptyView(isNewAccount)
}
IP2Country.configureIfNeeded(this@HomeActivity)
@ -231,67 +239,25 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
// Get group results and display them
launch {
globalSearchViewModel.result.collect { result ->
if (result.query.isEmpty()) {
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
listOf(
GlobalSearchAdapter.Model.Header(R.string.contacts),
GlobalSearchAdapter.Model.SavedMessages(publicKey)
) + result.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.accountID == publicKey) }
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.contacts))
addAll(it)
}
result.messageResults.takeUnless { it.isEmpty() }?.let {
add(GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
addAll(it)
}
} else {
val contactAndGroupList = result.contacts.map { GlobalSearchAdapter.Model.Contact(it, it.accountID == publicKey) } +
result.threads.map(GlobalSearchAdapter.Model::GroupConversation)
val contactResults = contactAndGroupList.toMutableList()
if (contactResults.isNotEmpty()) {
contactResults.add(0, GlobalSearchAdapter.Model.Header(R.string.conversations))
}
val unreadThreadMap = result.messages
.map { it.threadId }.toSet()
.associateWith { mmsSmsDatabase.getUnreadCount(it) }
val messageResults: MutableList<GlobalSearchAdapter.Model> = result.messages
.map { GlobalSearchAdapter.Model.Message(it, unreadThreadMap[it.threadId] ?: 0, it.conversationRecipient.isLocalNumber) }
.toMutableList()
if (messageResults.isNotEmpty()) {
messageResults.add(0, GlobalSearchAdapter.Model.Header(R.string.global_search_messages))
}
contactResults + messageResults
}.let { globalSearchAdapter.setNewData(result.query, it) }
}
}
}.collectLatest(globalSearchAdapter::setNewData)
}
}
EventBus.getDefault().register(this@HomeActivity)
@ -309,6 +275,54 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
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) {
setSearchShown(hasFocus || binding.globalSearchInputLayout.query.value.isNotEmpty())
}
@ -620,9 +634,10 @@ class HomeActivity : PassphraseRequiredActionBarActivity(),
}
}
fun Context.startHomeActivity() {
fun Context.startHomeActivity(isNewAccount: Boolean) {
Intent(this, HomeActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra(HomeActivity.NEW_ACCOUNT, true)
putExtra(HomeActivity.FROM_ONBOARDING, true)
}.also(::startActivity)
}

View File

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

View File

@ -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 {

View File

@ -28,6 +28,8 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
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
@ -134,7 +136,7 @@ class GlobalSearchAdapter(private val modelCallback: (Model)->Unit): RecyclerVie
constructor(title: String): this(GetString(title))
}
data class SavedMessages(val currentUserPublicKey: String): Model()
data class Contact(val contact: ContactModel, val isSelf: Boolean): Model()
data class Contact(val contact: ContactModel, val name: String?, val isSelf: Boolean): Model()
data class GroupConversation(val groupRecord: GroupRecord): Model()
data class Message(val messageResult: MessageResult, val unread: Int, val isSelf: Boolean): Model()
}

View File

@ -6,22 +6,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
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.combine
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.session.libsignal.utilities.SettableFuture
@ -35,16 +31,32 @@ import javax.inject.Inject
class GlobalSearchViewModel @Inject constructor(
private val searchRepository: SearchRepository,
) : ViewModel() {
private val executor = viewModelScope + SupervisorJob()
private val _result: MutableStateFlow<GlobalSearchResult> = MutableStateFlow(GlobalSearchResult.EMPTY)
val result: StateFlow<GlobalSearchResult> = _result
private val scope = viewModelScope + SupervisorJob()
private val refreshes = MutableSharedFlow<Unit>()
private val _queryText = MutableStateFlow<CharSequence>("")
private val _queryText: MutableStateFlow<CharSequence> = MutableStateFlow("")
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())
}
}
}
fun setQuery(charSequence: CharSequence) {
_queryText.value = charSequence
@ -55,34 +67,6 @@ class GlobalSearchViewModel @Inject constructor(
refreshes.emit(Unit)
}
}
init {
_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())
}
}
}.onEach { result ->
// update the latest _result value
_result.value = result
}.launchIn(executor)
}
}
/**

View File

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

View File

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

View File

@ -16,24 +16,23 @@
*/
package org.thoughtcrime.securesms.mms;
import static org.session.libsession.utilities.StringSubstitutionConstants.EMOJI_KEY;
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 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.Util;
import java.security.SecureRandom;
import network.loki.messenger.R;
import org.session.libsignal.utilities.guava.Optional;
import org.thoughtcrime.securesms.util.MediaUtil;
public abstract class Slide {
@ -72,20 +71,23 @@ public abstract class Slide {
return Optional.fromNullable("🎤 " + attachmentString);
}
}
return Optional.fromNullable(emojiForMimeType() + attachmentString);
String txt = Phrase.from(context, R.string.attachmentsNotification)
.put(EMOJI_KEY, emojiForMimeType())
.format().toString();
return Optional.fromNullable(txt);
}
private String emojiForMimeType() {
if (MediaUtil.isImage(attachment)) {
return "📷 ";
return "📷";
} else if (MediaUtil.isVideo(attachment)) {
return "🎥 ";
return "🎥";
} else if (MediaUtil.isAudio(attachment)) {
return "🎧 ";
return "🎧";
} else if (MediaUtil.isFile(attachment)) {
return "📎 ";
return "📎";
} else {
return "🎡 ";
return "🎡"; // `isGif`
}
}
@ -155,20 +157,20 @@ public abstract class Slide {
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)
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());
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,

View File

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

View File

@ -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;
@ -146,9 +147,8 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
public void notifyMessagesPending(Context context) {
if (!MessagingModuleConfiguration.getShared().getPrefs().isNotificationsEnabled()) {
return;
}
if (!MessagingModuleConfiguration.getShared().getPrefs().isNotificationsEnabled()) { return; }
PendingMessageNotificationBuilder builder = new PendingMessageNotificationBuilder(context, MessagingModuleConfiguration.getShared().getPrefs().getNotificationPrivacy());
ServiceUtil.getNotificationManager(context).notify(PENDING_MESSAGES_ID, builder.build());
@ -186,9 +186,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()) {
@ -198,9 +198,7 @@ public class DefaultMessageNotifier implements MessageNotifier {
}
}
if (!validNotification) {
notifications.cancel(notification.getId());
}
if (!validNotification) { notifications.cancel(notification.getId()); }
}
}
} catch (Throwable e) {
@ -232,7 +230,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);
@ -349,14 +347,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));
@ -506,24 +509,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 = MessagingModuleConfiguration.getShared().getPrefs().getLocalNumber();
String blindedPublicKey = cache.get(threadId);
if (blindedPublicKey == null) {

View File

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

View File

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

View File

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.onboarding
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import network.loki.messenger.R
@ -11,12 +12,13 @@ import org.thoughtcrime.securesms.ui.color.LocalColors
@Composable
fun OnboardingBackPressAlertDialog(
dismissDialog: () -> Unit,
@StringRes textId: Int = R.string.you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit,
quit: () -> Unit
) {
AlertDialog(
onDismissRequest = dismissDialog,
title = stringResource(R.string.warning),
text = stringResource(R.string.you_cannot_go_back_further_in_order_to_stop_loading_your_account_session_needs_to_quit),
text = stringResource(textId),
buttons = listOf(
DialogButtonModel(
GetString(stringResource(R.string.quit)),

View File

@ -1,14 +1,10 @@
package org.thoughtcrime.securesms.onboarding.loading
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -20,21 +16,11 @@ import org.thoughtcrime.securesms.ui.contentDescription
import org.thoughtcrime.securesms.ui.h7
@Composable
internal fun LoadingScreen(state: State) {
val animatable = remember { Animatable(initialValue = 0f, visibilityThreshold = 0.005f) }
LaunchedEffect(state) {
animatable.stop()
animatable.animateTo(
targetValue = 1f,
animationSpec = TweenSpec(durationMillis = state.duration.inWholeMilliseconds.toInt())
)
}
internal fun LoadingScreen(progress: Float) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.weight(1f))
ProgressArc(
animatable.value,
progress,
modifier = Modifier.contentDescription(R.string.AccessibilityId_loading_animation)
)
Text(

View File

@ -8,7 +8,6 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import org.session.libsession.utilities.TextSecurePreferences
import org.thoughtcrime.securesms.ApplicationContext
import org.thoughtcrime.securesms.BaseActionBarActivity
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.startHomeActivity
@ -31,7 +30,7 @@ class LoadingActivity: BaseActionBarActivity() {
private fun register(loadFailed: Boolean) {
when {
loadFailed -> startPickDisplayNameActivity(loadFailed = true)
else -> startHomeActivity()
else -> startHomeActivity(isNewAccount = false)
}
finish()
@ -42,11 +41,9 @@ class LoadingActivity: BaseActionBarActivity() {
setUpActionBarSessionLogo()
ApplicationContext.getInstance(this).newAccount = false
setComposeContent {
val state by viewModel.states.collectAsState()
LoadingScreen(state)
val progress by viewModel.progress.collectAsState()
LoadingScreen(progress)
}
lifecycleScope.launch {

View File

@ -4,14 +4,21 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
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.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -21,25 +28,43 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class State(val duration: Duration)
enum class State {
LOADING,
SUCCESS,
FAIL
}
private val ANIMATE_TO_DONE_TIME = 500.milliseconds
private val IDLE_DONE_TIME = 1.seconds
private val TIMEOUT_TIME = 15.seconds
@OptIn(FlowPreview::class)
private val REFRESH_TIME = 50.milliseconds
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@HiltViewModel
internal class LoadingViewModel @Inject constructor(
val prefs: TextSecurePreferences
): ViewModel() {
private val _states = MutableStateFlow(State(TIMEOUT_TIME))
val states = _states.asStateFlow()
private val state = MutableStateFlow(State.LOADING)
private val _progress = MutableStateFlow(0f)
val progress = _progress.asStateFlow()
private val _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
state.flatMapLatest {
when (it) {
State.LOADING -> progress(0f, 1f, TIMEOUT_TIME)
else -> progress(progress.value, 1f, ANIMATE_TO_DONE_TIME)
}
}.buffer(0, BufferOverflow.DROP_OLDEST)
.collectLatest { _progress.value = it }
}
viewModelScope.launch(Dispatchers.IO) {
try {
prefs.configurationMessageSyncedFlow()
@ -54,7 +79,7 @@ internal class LoadingViewModel @Inject constructor(
private suspend fun onSuccess() {
withContext(Dispatchers.Main) {
_states.value = State(ANIMATE_TO_DONE_TIME)
state.value = State.SUCCESS
delay(IDLE_DONE_TIME)
_events.emit(Event.SUCCESS)
}
@ -62,6 +87,8 @@ internal class LoadingViewModel @Inject constructor(
private suspend fun onFail() {
withContext(Dispatchers.Main) {
state.value = State.FAIL
delay(IDLE_DONE_TIME)
_events.emit(Event.TIMEOUT)
}
}
@ -71,3 +98,22 @@ sealed interface Event {
object SUCCESS: Event
object TIMEOUT: Event
}
private fun progress(
init: Float,
target: Float,
time: Duration,
refreshRate: Duration = REFRESH_TIME
): Flow<Float> = flow {
val startMs = System.currentTimeMillis()
val timeMs = time.inWholeMilliseconds
val finishMs = startMs + timeMs
val range = target - init
generateSequence { System.currentTimeMillis() }.takeWhile { it < finishMs }.forEach {
emit((it - startMs) * range / timeMs + init)
delay(refreshRate)
}
emit(target)
}

View File

@ -58,7 +58,7 @@ internal fun MessageNotificationsScreen(
return
}
if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit)
if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit = quit)
Column {
Spacer(Modifier.weight(1f))

View File

@ -49,7 +49,7 @@ class MessageNotificationsActivity : BaseActionBarActivity() {
viewModel.events.collect {
when (it) {
Event.Loading -> start<LoadingActivity>()
Event.OnboardingComplete -> startHomeActivity()
Event.OnboardingComplete -> startHomeActivity(isNewAccount = true)
}
}
}

View File

@ -40,7 +40,11 @@ internal fun PickDisplayName(
quit: () -> Unit = {}
) {
if (state.showDialog) OnboardingBackPressAlertDialog(dismissDialog, quit)
if (state.showDialog) OnboardingBackPressAlertDialog(
dismissDialog,
R.string.you_cannot_go_back_further_cancel_account_creation,
quit
)
Column(
modifier = Modifier

View File

@ -45,7 +45,7 @@ class PickDisplayNameActivity : BaseActionBarActivity() {
viewModel.events.collect {
when (it) {
is Event.CreateAccount -> startMessageNotificationsActivity(it.profileName)
Event.LoadAccountComplete -> startHomeActivity()
Event.LoadAccountComplete -> startHomeActivity(isNewAccount = false)
}
}
}
@ -72,8 +72,6 @@ class PickDisplayNameActivity : BaseActionBarActivity() {
}
fun Context.startPickDisplayNameActivity(loadFailed: Boolean = false, flags: Int = 0) {
ApplicationContext.getInstance(this).newAccount = !loadFailed
Intent(this, PickDisplayNameActivity::class.java)
.apply { putExtra(EXTRA_LOAD_FAILED, loadFailed) }
.also { it.flags = flags }

View File

@ -74,7 +74,7 @@ class QRCodeActivity : PassphraseRequiredActionBarActivity() {
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Tabs(sessionId: String, errors: Flow<String>, onScan: (String) -> Unit) {
private fun Tabs(accountId: String, errors: Flow<String>, onScan: (String) -> Unit) {
val pagerState = rememberPagerState { TITLES.size }
Column {
@ -84,7 +84,7 @@ private fun Tabs(sessionId: String, errors: Flow<String>, onScan: (String) -> Un
modifier = Modifier.weight(1f)
) { page ->
when (TITLES[page]) {
R.string.view -> QrPage(sessionId)
R.string.view -> QrPage(accountId)
R.string.scan -> MaybeScanQrCode(errors, onScan = onScan)
}
}

View File

@ -46,9 +46,8 @@ import network.loki.messenger.BuildConfig
import network.loki.messenger.R
import network.loki.messenger.databinding.ActivitySettingsBinding
import network.loki.messenger.libsession_util.util.UserPic
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.all
import nl.komponents.kovenant.ui.alwaysUi
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.avatars.ProfileContactPhoto
@ -87,16 +86,16 @@ import org.thoughtcrime.securesms.ui.setThemedContent
import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities
import org.thoughtcrime.securesms.util.NetworkUtils
import org.thoughtcrime.securesms.util.push
import org.thoughtcrime.securesms.util.show
import java.io.File
import java.security.SecureRandom
import javax.inject.Inject
private const val TAG = "SettingsActivity"
@AndroidEntryPoint
class SettingsActivity : PassphraseRequiredActionBarActivity() {
private val TAG = "SettingsActivity"
@Inject
lateinit var configFactory: ConfigFactory
@ -146,7 +145,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
view.apply {
publicKey = hexEncodedPublicKey
displayName = getDisplayName()
isLarge = true
update()
}
}
@ -198,7 +196,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
try {
val profilePictureToBeUploaded = BitmapUtil.createScaledBytes(this@SettingsActivity, AvatarSelection.getResultUri(data), ProfileMediaConstraints()).bitmap
launch(Dispatchers.Main) {
updateProfile(true, profilePictureToBeUploaded)
updateProfilePicture(profilePictureToBeUploaded)
}
} catch (e: BitmapDecodingException) {
Log.e(TAG, e)
@ -247,54 +245,118 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
}
}
private fun updateProfile(
isUpdatingProfilePicture: Boolean,
profilePicture: ByteArray? = null,
displayName: String? = null
) {
private fun updateDisplayName(displayName: String): Boolean {
binding.loader.isVisible = true
val promises = mutableListOf<Promise<*, Exception>>()
if (displayName != null) {
// We'll assume we fail & flip the flag on success
var updateWasSuccessful = false
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(this@SettingsActivity);
if (!haveNetworkConnection) {
Log.w(TAG, "Cannot update display name - no network connection.")
} else {
// if we have a network connection then attempt to update the display name
prefs.setProfileName(displayName)
configFactory.user?.setName(displayName)
}
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this)
if (isUpdatingProfilePicture) {
if (profilePicture != null) {
promises.add(ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this))
val user = configFactory.user
if (user == null) {
Log.w(TAG, "Cannot update display name - missing user details from configFactory.")
} else {
user.setName(displayName)
binding.btnGroupNameDisplay.text = displayName
updateWasSuccessful = true
}
}
// Inform the user if we failed to update the display name
if (!updateWasSuccessful) {
Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
}
binding.loader.isVisible = false
return updateWasSuccessful
}
// Helper method used by updateProfilePicture and removeProfilePicture to sync it online
private fun syncProfilePicture(profilePicture: ByteArray, onFail: () -> Unit) {
binding.loader.isVisible = true
// Grab the profile key and kick of the promise to update the profile picture
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(this)
val updateProfilePicturePromise = ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, this)
// If the online portion of the update succeeded then update the local state
updateProfilePicturePromise.successUi {
// When removing the profile picture the supplied ByteArray is empty so we'll clear the local data
if (profilePicture.isEmpty()) {
MessagingModuleConfiguration.shared.storage.clearUserPic()
}
}
all(promises) successUi { // Do this on the UI thread so that it happens before the alwaysUi clause below
val userConfig = configFactory.user
if (isUpdatingProfilePicture) {
AvatarHelper.setAvatar(this, Address.fromSerialized(prefs.getLocalNumber()!!), profilePicture)
prefs.setProfileAvatarId(profilePicture?.let { SecureRandom().nextInt() } ?: 0 )
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
// new config
val url = prefs.getProfilePictureURL()
val profileKey = ProfileKeyUtil.getProfileKey(this)
if (profilePicture == null) {
userConfig?.setPic(UserPic.DEFAULT)
} else if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) {
userConfig?.setPic(UserPic(url, profileKey))
}
AvatarHelper.setAvatar(this, Address.fromSerialized(prefs.getLocalNumber()!!), profilePicture)
prefs.setProfileAvatarId(SecureRandom().nextInt() )
ProfileKeyUtil.setEncodedProfileKey(this, encodedProfileKey)
// Attempt to grab the details we require to update the profile picture
val url = prefs.getProfilePictureURL()
val profileKey = ProfileKeyUtil.getProfileKey(this)
// If we have a URL and a profile key then set the user's profile picture
if (!url.isNullOrEmpty() && profileKey.isNotEmpty()) {
userConfig?.setPic(UserPic(url, profileKey))
}
if (userConfig != null && userConfig.needsDump()) {
configFactory.persist(userConfig, SnodeAPI.nowWithOffset)
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(this@SettingsActivity)
} alwaysUi {
if (displayName != null) {
binding.btnGroupNameDisplay.text = displayName
}
if (isUpdatingProfilePicture) {
binding.profilePictureView.recycle() // Clear the cached image before updating
binding.profilePictureView.update()
}
binding.loader.isVisible = false
// Update our visuals
binding.profilePictureView.recycle()
binding.profilePictureView.update()
}
// If the sync failed then inform the user
updateProfilePicturePromise.failUi { onFail() }
// Finally, remove the loader animation after we've waited for the attempt to succeed or fail
updateProfilePicturePromise.alwaysUi { binding.loader.isVisible = false }
}
private fun updateProfilePicture(profilePicture: ByteArray) {
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(this@SettingsActivity);
if (!haveNetworkConnection) {
Log.w(TAG, "Cannot update profile picture - no network connection.")
Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
return
}
val onFail: () -> Unit = {
Log.e(TAG, "Sync failed when uploading profile picture.")
Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
}
syncProfilePicture(profilePicture, onFail)
}
private fun removeProfilePicture() {
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(this@SettingsActivity);
if (!haveNetworkConnection) {
Log.w(TAG, "Cannot remove profile picture - no network connection.")
Toast.makeText(this@SettingsActivity, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show()
return
}
val onFail: () -> Unit = {
Log.e(TAG, "Sync failed when removing profile picture.")
Toast.makeText(this@SettingsActivity, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show()
}
val emptyProfilePicture = ByteArray(0)
syncProfilePicture(emptyProfilePicture, onFail)
}
// endregion
@ -313,8 +375,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
Toast.makeText(this, R.string.activity_settings_display_name_too_long_error, Toast.LENGTH_SHORT).show()
return false
}
updateProfile(false, displayName = displayName)
return true
return updateDisplayName(displayName)
}
private fun showEditProfilePictureUI() {
@ -323,7 +384,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
view(R.layout.dialog_change_avatar)
button(R.string.activity_settings_upload) { startAvatarSelection() }
if (prefs.getProfileAvatarId() != 0) {
button(R.string.activity_settings_remove) { removeAvatar() }
button(R.string.activity_settings_remove) { removeProfilePicture() }
}
cancelButton()
}.apply {
@ -341,10 +402,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
}
}
private fun removeAvatar() {
updateProfile(true)
}
private fun startAvatarSelection() {
// Ask for an optional camera permission.
Permissions.with(this)

View File

@ -293,7 +293,7 @@ class DefaultConversationRepository @Inject constructor(
override suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): ResultOf<Unit> =
suspendCoroutine { continuation ->
// Note: This sessionId could be the blinded Id
// Note: This accountId could be the blinded Id
val accountID = recipient.address.toString()
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!!

View File

@ -81,6 +81,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener {
const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID"
const val EXTRA_ENABLED = "ENABLED"
const val EXTRA_AUDIO_COMMAND = "AUDIO_COMMAND"
const val EXTRA_SWAPPED = "is_video_swapped"
const val EXTRA_MUTE = "mute_value"
const val EXTRA_AVAILABLE = "enabled_value"
const val EXTRA_REMOTE_DESCRIPTION = "remote_description"

View File

@ -94,8 +94,8 @@ class ProfileManager(private val context: Context, private val configFactory: Co
override fun contactUpdatedInternal(contact: Contact): String? {
val contactConfig = configFactory.contacts ?: return null
if (contact.accountID == context.prefs.getLocalNumber()) return null
val sessionId = AccountId(contact.accountID)
if (sessionId.prefix != IdPrefix.STANDARD) return null // only internally store standard session IDs
val accountId = AccountId(contact.accountID)
if (accountId.prefix != IdPrefix.STANDARD) return null // only internally store standard account IDs
contactConfig.upsertContact(contact.accountID) {
this.name = contact.name.orEmpty()
this.nickname = contact.nickname.orEmpty()

View File

@ -24,6 +24,7 @@ import org.session.libsession.utilities.prefs
import org.session.libsignal.crypto.ecc.DjbECPublicKey
import org.session.libsignal.utilities.Hex
import org.session.libsignal.utilities.IdPrefix
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.toHexString
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase
@ -31,10 +32,12 @@ import org.thoughtcrime.securesms.dependencies.DatabaseComponent
import java.util.Timer
object ConfigurationMessageUtilities {
private const val TAG = "ConfigMessageUtils"
private val debouncer = WindowDebouncer(3000, Timer())
private fun scheduleConfigSync(userPublicKey: String) {
debouncer.publish {
// don't schedule job if we already have one
val storage = MessagingModuleConfiguration.shared.storage
@ -44,23 +47,20 @@ object ConfigurationMessageUtilities {
(currentStorageJob as ConfigurationSyncJob).shouldRunAgain.set(true)
return@publish
}
val newConfigSync = ConfigurationSyncJob(ourDestination)
JobQueue.shared.add(newConfigSync)
val newConfigSyncJob = ConfigurationSyncJob(ourDestination)
JobQueue.shared.add(newConfigSyncJob)
}
}
@JvmStatic
fun syncConfigurationIfNeeded(context: Context) {
// add if check here to schedule new config job process and return early
val userPublicKey = context.prefs.getLocalNumber() ?: return
val userPublicKey = context.prefs.getLocalNumber() ?: return Log.w(TAG, "User Public Key is null")
scheduleConfigSync(userPublicKey)
}
fun forceSyncConfigurationNowIfNeeded(context: Context): Promise<Unit, Exception> {
// add if check here to schedule new config job process and return early
val userPublicKey = context.prefs.getLocalNumber() ?: return Promise.ofFail(NullPointerException("User Public Key is null"))
// schedule job if none exist
// don't schedule job if we already have one
// Schedule a new job if one doesn't already exist (only)
scheduleConfigSync(userPublicKey)
return Promise.ofSuccess(Unit)
}
@ -205,7 +205,7 @@ object ConfigurationMessageUtilities {
val admins = group.admins.map { it.serialize() to true }.toMap()
val members = group.members.filterNot { it.serialize() !in admins.keys }.map { it.serialize() to false }.toMap()
GroupInfo.LegacyGroupInfo(
sessionId = groupPublicKey,
accountId = groupPublicKey,
name = group.title,
members = admins + members,
priority = if (isPinned) ConfigBase.PRIORITY_PINNED else ConfigBase.PRIORITY_VISIBLE,

View File

@ -99,7 +99,7 @@ class IP2Country private constructor(private val context: Context) {
val bestMatchCountry = comps.lastOrNull { it.key <= Ipv4Int(ip) }?.let { (_, code) ->
if (code != null) {
countryToNames[code] + " [" + ip + "]"
countryToNames[code]
} else {
null
}

View File

@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.util
import android.content.Context
import android.content.res.Resources
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
@ -50,6 +49,17 @@ class RoundedBackgroundSpan(
override fun getSize(
paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?
): Int {
// If the span covers the whole text, and the height is not set, draw() will not be called for the span.
// To help with that we need to take the font metric into account
val metrics = paint.fontMetricsInt
if (fm != null) {
fm.top = metrics.top
fm.ascent = metrics.ascent
fm.descent = metrics.descent
fm.bottom = metrics.bottom
}
return (paint.measureText(text, start, end) + 2 * paddingHorizontal).toInt()
}

View File

@ -6,11 +6,14 @@ import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.functional.bind
@ -51,12 +54,14 @@ import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnection.IceConnectionState
import org.webrtc.PeerConnectionFactory
import org.webrtc.RendererCommon
import org.webrtc.RtpReceiver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceViewRenderer
import java.nio.ByteBuffer
import java.util.ArrayDeque
import java.util.UUID
import kotlin.math.abs
import org.thoughtcrime.securesms.webrtc.data.State as CallState
class CallManager(
@ -105,10 +110,15 @@ class CallManager(
private val _audioEvents = MutableStateFlow(AudioEnabled(false))
val audioEvents = _audioEvents.asSharedFlow()
private val _videoEvents = MutableStateFlow(VideoEnabled(false))
val videoEvents = _videoEvents.asSharedFlow()
private val _remoteVideoEvents = MutableStateFlow(VideoEnabled(false))
val remoteVideoEvents = _remoteVideoEvents.asSharedFlow()
private val _videoState: MutableStateFlow<VideoState> = MutableStateFlow(
VideoState(
swapped = false,
userVideoEnabled = false,
remoteVideoEnabled = false
)
)
val videoState = _videoState
private val stateProcessor = StateProcessor(CallState.Idle)
@ -151,9 +161,9 @@ class CallManager(
private val outgoingIceDebouncer = Debouncer(200L)
var localRenderer: SurfaceViewRenderer? = null
var floatingRenderer: SurfaceViewRenderer? = null
var remoteRotationSink: RemoteRotationVideoProxySink? = null
var remoteRenderer: SurfaceViewRenderer? = null
var fullscreenRenderer: SurfaceViewRenderer? = null
private var peerConnectionFactory: PeerConnectionFactory? = null
fun clearPendingIceUpdates() {
@ -216,20 +226,18 @@ class CallManager(
Util.runOnMainSync {
val base = EglBase.create()
eglBase = base
localRenderer = SurfaceViewRenderer(context).apply {
// setScalingType(SCALE_ASPECT_FIT)
}
floatingRenderer = SurfaceViewRenderer(context)
floatingRenderer?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
fullscreenRenderer = SurfaceViewRenderer(context)
fullscreenRenderer?.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
remoteRenderer = SurfaceViewRenderer(context).apply {
// setScalingType(SCALE_ASPECT_FIT)
}
remoteRotationSink = RemoteRotationVideoProxySink()
localRenderer?.init(base.eglBaseContext, null)
localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT)
remoteRenderer?.init(base.eglBaseContext, null)
remoteRotationSink!!.setSink(remoteRenderer!!)
floatingRenderer?.init(base.eglBaseContext, null)
fullscreenRenderer?.init(base.eglBaseContext, null)
remoteRotationSink!!.setSink(fullscreenRenderer!!)
val encoderFactory = DefaultVideoEncoderFactory(base.eglBaseContext, true, true)
val decoderFactory = DefaultVideoDecoderFactory(base.eglBaseContext)
@ -363,7 +371,8 @@ class CallManager(
val byteArray = ByteArray(buffer.data.remaining()) { buffer.data[it] }
val json = Json.parseToJsonElement(byteArray.decodeToString()) as JsonObject
if (json.containsKey("video")) {
_remoteVideoEvents.value = VideoEnabled((json["video"] as JsonPrimitive).boolean)
_videoState.update { it.copy(remoteVideoEnabled = json["video"]?.jsonPrimitive?.boolean ?: false) }
handleMirroring()
} else if (json.containsKey("hangup")) {
peerConnectionObservers.forEach(WebRtcListener::onHangup)
}
@ -383,13 +392,13 @@ class CallManager(
peerConnection?.dispose()
peerConnection = null
localRenderer?.release()
floatingRenderer?.release()
remoteRotationSink?.release()
remoteRenderer?.release()
fullscreenRenderer?.release()
eglBase?.release()
localRenderer = null
remoteRenderer = null
floatingRenderer = null
fullscreenRenderer = null
eglBase = null
localCameraState = CameraState.UNKNOWN
@ -399,8 +408,11 @@ class CallManager(
pendingOffer = null
callStartTime = -1
_audioEvents.value = AudioEnabled(false)
_videoEvents.value = VideoEnabled(false)
_remoteVideoEvents.value = VideoEnabled(false)
_videoState.value = VideoState(
swapped = false,
userVideoEnabled = false,
remoteVideoEnabled = false
)
pendingOutgoingIceUpdates.clear()
pendingIncomingIceUpdates.clear()
}
@ -411,7 +423,7 @@ class CallManager(
// If the camera we've switched to is the front one then mirror it to match what someone
// would see when looking in the mirror rather than the left<-->right flipped version.
localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT)
handleMirroring()
}
fun onPreOffer(callId: UUID, recipient: Recipient, onSuccess: () -> Unit) {
@ -469,7 +481,7 @@ class CallManager(
val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null"))
val offer = pendingOffer ?: return Promise.ofFail(NullPointerException("pendingOffer is null"))
val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null"))
val local = localRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null"))
val local = floatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null"))
val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null"))
val connection = PeerConnectionWrapper(
context,
@ -515,7 +527,7 @@ class CallManager(
?: return Promise.ofFail(NullPointerException("recipient is null"))
val factory = peerConnectionFactory
?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null"))
val local = localRenderer
val local = floatingRenderer
?: return Promise.ofFail(NullPointerException("localRenderer is null"))
val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null"))
@ -609,13 +621,58 @@ class CallManager(
}
}
fun swapVideos() {
// update the state
_videoState.update { it.copy(swapped = !it.swapped) }
handleMirroring()
if (_videoState.value.swapped) {
peerConnection?.rotationVideoSink?.setSink(fullscreenRenderer)
floatingRenderer?.let{remoteRotationSink?.setSink(it) }
} else {
peerConnection?.rotationVideoSink?.apply {
setSink(floatingRenderer)
}
fullscreenRenderer?.let{ remoteRotationSink?.setSink(it) }
}
}
fun handleSetMuteAudio(muted: Boolean) {
_audioEvents.value = AudioEnabled(!muted)
peerConnection?.setAudioEnabled(!muted)
}
/**
* Returns the renderer currently showing the user's video, not the contact's
*/
private fun getUserRenderer() = if(_videoState.value.swapped) fullscreenRenderer else floatingRenderer
/**
* Returns the renderer currently showing the contact's video, not the user's
*/
private fun getRemoteRenderer() = if(_videoState.value.swapped) floatingRenderer else fullscreenRenderer
/**
* Makes sure the user's renderer applies mirroring if necessary
*/
private fun handleMirroring() {
val videoState = _videoState.value
// if we have user video and the camera is front facing, make sure to mirror stream
if(videoState.userVideoEnabled) {
getUserRenderer()?.setMirror(isCameraFrontFacing())
}
// the remote video is never mirrored
if(videoState.remoteVideoEnabled){
getRemoteRenderer()?.setMirror(false)
}
}
fun handleSetMuteVideo(muted: Boolean, lockManager: LockManager) {
_videoEvents.value = VideoEnabled(!muted)
_videoState.update { it.copy(userVideoEnabled = !muted) }
handleMirroring()
val connection = peerConnection ?: return
connection.setVideoEnabled(!muted)
dataChannel?.let { channel ->
@ -651,9 +708,18 @@ class CallManager(
}
}
fun setDeviceRotation(newRotation: Int) {
peerConnection?.setDeviceRotation(newRotation)
remoteRotationSink?.rotation = newRotation
fun setDeviceOrientation(orientation: Orientation) {
// set rotation to the video based on the device's orientation and the camera facing direction
val rotation = when (orientation) {
Orientation.PORTRAIT -> 0
Orientation.LANDSCAPE -> if (isCameraFrontFacing()) 90 else -90
Orientation.REVERSED_LANDSCAPE -> 270
else -> 0
}
// apply the rotation to the streams
peerConnection?.setDeviceRotation(rotation)
remoteRotationSink?.rotation = abs(rotation) // abs as we never need the remote video to be inverted
}
fun handleWiredHeadsetChanged(present: Boolean) {
@ -721,7 +787,7 @@ class CallManager(
connection.setCommunicationMode()
setAudioEnabled(true)
dataChannel?.let { channel ->
val toSend = if (!_videoEvents.value.isEnabled) VIDEO_DISABLED_JSON else VIDEO_ENABLED_JSON
val toSend = if (_videoState.value.userVideoEnabled) VIDEO_ENABLED_JSON else VIDEO_DISABLED_JSON
val buffer = DataChannel.Buffer(ByteBuffer.wrap(toSend.toString().encodeToByteArray()), false)
channel.send(buffer)
}
@ -750,6 +816,8 @@ class CallManager(
fun isInitiator(): Boolean = peerConnection?.isInitiator() == true
fun isCameraFrontFacing() = localCameraState.activeDirection != CameraState.Direction.BACK
interface WebRtcListener: PeerConnection.Observer {
fun onHangup()
}

View File

@ -2,6 +2,8 @@ package org.thoughtcrime.securesms.webrtc
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
@ -29,16 +31,11 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V
UNTRUSTED_IDENTITY,
}
val localRenderer: SurfaceViewRenderer?
get() = callManager.localRenderer
val floatingRenderer: SurfaceViewRenderer?
get() = callManager.floatingRenderer
val remoteRenderer: SurfaceViewRenderer?
get() = callManager.remoteRenderer
private var _videoEnabled: Boolean = false
val videoEnabled: Boolean
get() = _videoEnabled
val fullscreenRenderer: SurfaceViewRenderer?
get() = callManager.fullscreenRenderer
private var _microphoneEnabled: Boolean = true
@ -59,18 +56,13 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V
get() = callManager.audioEvents.map { it.isEnabled }
.onEach { _microphoneEnabled = it }
val localVideoEnabledState
get() = callManager.videoEvents
.map { it.isEnabled }
.onEach { _videoEnabled = it }
val videoState: StateFlow<VideoState>
get() = callManager.videoState
val remoteVideoEnabledState
get() = callManager.remoteVideoEvents.map { it.isEnabled }
var deviceRotation: Int = 0
var deviceOrientation: Orientation = Orientation.UNKNOWN
set(value) {
field = value
callManager.setDeviceRotation(value)
callManager.setDeviceOrientation(value)
}
val currentCallState
@ -85,4 +77,7 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V
val callStartTime: Long
get() = callManager.callStartTime
fun swapVideos() {
callManager.swapVideos()
}
}

View File

@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.webrtc
enum class Orientation {
PORTRAIT,
LANDSCAPE,
REVERSED_LANDSCAPE,
UNKNOWN
}

View File

@ -41,7 +41,7 @@ class PeerConnectionWrapper(private val context: Context,
private val mediaStream: MediaStream
private val videoSource: VideoSource?
private val videoTrack: VideoTrack?
private val rotationVideoSink = RotationVideoSink()
public val rotationVideoSink = RotationVideoSink()
val readyForIce
get() = peerConnection?.localDescription != null && peerConnection?.remoteDescription != null
@ -103,7 +103,7 @@ class PeerConnectionWrapper(private val context: Context,
context,
rotationVideoSink
)
rotationVideoSink.mirrored = newCamera.activeDirection == CameraState.Direction.FRONT
rotationVideoSink.setSink(localRenderer)
newVideoTrack.setEnabled(false)
mediaStream.addTrack(newVideoTrack)

View File

@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.webrtc
data class VideoState (
val swapped: Boolean,
val userVideoEnabled: Boolean,
val remoteVideoEnabled: Boolean
){
fun showFloatingVideo(): Boolean {
return userVideoEnabled && !swapped ||
remoteVideoEnabled && swapped
}
fun showFullscreenVideo(): Boolean {
return userVideoEnabled && swapped ||
remoteVideoEnabled && !swapped
}
}

View File

@ -1,11 +0,0 @@
package org.thoughtcrime.securesms.webrtc.data
// get the video rotation from a specific rotation, locked into 90 degree
// chunks offset by 45 degrees
fun Int.quadrantRotation() = when (this % 360) {
in 315 .. 360,
in 0 until 45 -> 0
in 45 until 135 -> 90
in 135 until 225 -> 180
else -> 270
}

View File

@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.webrtc.video
import org.thoughtcrime.securesms.webrtc.data.quadrantRotation
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
@ -14,8 +14,7 @@ class RemoteRotationVideoProxySink: VideoSink {
val thisSink = targetSink ?: return
val thisFrame = frame ?: return
val quadrantRotation = rotation.quadrantRotation()
val modifiedRotation = thisFrame.rotation - quadrantRotation
val modifiedRotation = thisFrame.rotation - rotation
val newFrame = VideoFrame(thisFrame.buffer, modifiedRotation, thisFrame.timestampNs)
thisSink.onFrame(newFrame)

View File

@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.webrtc.video
import org.session.libsignal.utilities.Log
import org.thoughtcrime.securesms.webrtc.data.quadrantRotation
import org.webrtc.CapturerObserver
import org.webrtc.VideoFrame
import org.webrtc.VideoProcessor
@ -12,7 +11,6 @@ import java.util.concurrent.atomic.AtomicBoolean
class RotationVideoSink: CapturerObserver, VideoProcessor {
var rotation: Int = 0
var mirrored = false
private val capturing = AtomicBoolean(false)
private var capturerObserver = SoftReference<CapturerObserver>(null)
@ -31,13 +29,14 @@ class RotationVideoSink: CapturerObserver, VideoProcessor {
val observer = capturerObserver.get()
if (videoFrame == null || observer == null || !capturing.get()) return
val quadrantRotation = rotation.quadrantRotation()
val newFrame = VideoFrame(videoFrame.buffer, (videoFrame.rotation + quadrantRotation * if (mirrored && quadrantRotation in listOf(90,270)) -1 else 1) % 360, videoFrame.timestampNs)
val localFrame = VideoFrame(videoFrame.buffer, videoFrame.rotation * if (mirrored && quadrantRotation in listOf(90,270)) -1 else 1, videoFrame.timestampNs)
// cater for frame rotation so that the video is always facing up as we rotate pas a certain point
val newFrame = VideoFrame(videoFrame.buffer, videoFrame.rotation - rotation, videoFrame.timestampNs)
// the frame we are sending to our contact needs to cater for rotation
observer.onFrameCaptured(newFrame)
sink.get()?.onFrame(localFrame)
// the frame we see on the user's phone doesn't require changes
sink.get()?.onFrame(videoFrame)
}
override fun setSink(sink: VideoSink?) {

View File

@ -4,6 +4,6 @@
android:color="?android:colorControlHighlight">
<item>
<color android:color="?colorCellBackground" />
<color android:color="?colorPrimary" />
</item>
</ripple>

View File

@ -3,7 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?dialog_background_color" />
<solid android:color="?backgroundSecondary" />
<corners
android:topLeftRadius="@dimen/dialog_corner_radius"

View File

@ -3,7 +3,7 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="?attr/dialog_background_color" />
<solid android:color="?backgroundSecondary" />
<corners android:radius="?dialogCornerRadius" />

View File

@ -6,6 +6,6 @@
android:insetBottom="16dp">
<shape android:shape="rectangle">
<corners android:radius="2dp" />
<solid android:color="?dialog_background_color" />
<solid android:color="?backgroundSecondary" />
</shape>
</inset>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M4,7.59l5,-5c0.78,-0.78 2.05,-0.78 2.83,0L20.24,11h-2.83L10.4,4L5.41,9H8v2H2V5h2V7.59zM20,19h2v-6h-6v2h2.59l-4.99,5l-7.01,-7H3.76l8.41,8.41c0.78,0.78 2.05,0.78 2.83,0l5,-5V19z"/>
</vector>

View File

@ -7,5 +7,5 @@
android:pathData="M19.907,7.674H19.907H4.54H4.54C4.317,7.674 4.095,7.719 3.888,7.806L3.888,7.806C3.681,7.893 3.491,8.023 3.334,8.189C3.176,8.355 3.054,8.554 2.978,8.775L3.922,9.097L2.978,8.775C2.903,8.996 2.877,9.231 2.904,9.465L2.904,9.465L2.904,9.469L4.555,23.412C4.555,23.413 4.555,23.413 4.555,23.414C4.603,23.823 4.807,24.189 5.111,24.447C5.415,24.705 5.798,24.84 6.187,24.84H6.188H18.26H18.26C18.649,24.84 19.032,24.705 19.336,24.447C19.64,24.189 19.844,23.823 19.892,23.414C19.892,23.413 19.892,23.413 19.892,23.412L21.543,9.469L21.544,9.465C21.57,9.231 21.544,8.996 21.469,8.775L21.469,8.775C21.393,8.554 21.271,8.355 21.113,8.189C20.956,8.023 20.766,7.893 20.559,7.806L20.17,8.728L20.559,7.806C20.352,7.719 20.13,7.674 19.907,7.674ZM21.412,1.84H3.031C2.045,1.84 1.149,2.609 1.149,3.674V5.828C1.149,6.893 2.045,7.662 3.031,7.662H21.412C22.398,7.662 23.294,6.893 23.294,5.828V3.674C23.294,2.609 22.398,1.84 21.412,1.84Z"
android:strokeWidth="2"
android:fillColor="#FF3A3A"
android:strokeColor="?colorPrimaryDark"/>
android:strokeColor="?backgroundSecondary"/>
</vector>

View File

@ -6,7 +6,7 @@
android:bottom="@dimen/small_spacing"
>
<shape android:shape="rectangle">
<solid android:color="?colorSettingsBackground"/>
<solid android:color="?backgroundSecondary"/>
<corners android:bottomLeftRadius="?preferenceCornerRadius"
android:bottomRightRadius="?preferenceCornerRadius"/>
</shape>

View File

@ -4,7 +4,7 @@
<item android:left="@dimen/medium_spacing"
android:right="@dimen/medium_spacing">
<shape android:shape="rectangle">
<solid android:color="?colorSettingsBackground"/>
<solid android:color="?backgroundSecondary"/>
</shape>
</item>
<item android:gravity="bottom"

View File

@ -6,7 +6,7 @@
android:right="@dimen/medium_spacing"
android:bottom="@dimen/small_spacing">
<shape android:shape="rectangle">
<solid android:color="?colorSettingsBackground"/>
<solid android:color="?backgroundSecondary"/>
<corners android:radius="?preferenceCornerRadius"/>
</shape>
</item>

View File

@ -2,7 +2,7 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="?colorSettingsBackground"/>
<solid android:color="?backgroundSecondary"/>
<corners android:radius="?preferenceCornerRadius"/>
</shape>
</item>

View File

@ -6,7 +6,7 @@
android:top="@dimen/small_spacing"
>
<shape android:shape="rectangle">
<solid android:color="?colorSettingsBackground"/>
<solid android:color="?backgroundSecondary"/>
<corners android:topLeftRadius="?preferenceCornerRadius"
android:topRightRadius="?preferenceCornerRadius"/>
</shape>

View File

@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
android:shape="oval">
<solid android:color="@color/profile_picture_background" />
<corners android:radius="40dp" />
</shape>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/profile_picture_background" />
<corners android:radius="23dp" />
</shape>

View File

@ -25,7 +25,7 @@
app:cardElevation="0dp"
app:cardCornerRadius="@dimen/dialog_corner_radius"
android:layout_marginHorizontal="@dimen/medium_spacing"
app:cardBackgroundColor="?colorSettingsBackground"
app:cardBackgroundColor="?backgroundSecondary"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
@ -317,7 +317,7 @@
<androidx.cardview.widget.CardView
app:cardElevation="0dp"
android:elevation="0dp"
app:cardBackgroundColor="?colorSettingsBackground"
app:cardBackgroundColor="?backgroundSecondary"
app:cardCornerRadius="@dimen/dialog_corner_radius"
android:layout_margin="@dimen/medium_spacing"
android:layout_marginBottom="@dimen/massive_spacing"

View File

@ -10,7 +10,7 @@
app:layout_constraintBottom_toTopOf="@+id/unblockButton"
app:cardCornerRadius="?preferenceCornerRadius"
app:cardElevation="0dp"
app:cardBackgroundColor="?colorSettingsBackground"
app:cardBackgroundColor="?backgroundSecondary"
android:layout_marginHorizontal="@dimen/medium_spacing"
android:layout_marginVertical="@dimen/large_spacing"
android:layout_width="match_parent"

View File

@ -8,7 +8,7 @@
xmlns:tools="http://schemas.android.com/tools">
<FrameLayout
android:id="@+id/remote_parent"
android:id="@+id/fullscreen_renderer_container"
android:background="@color/black"
android:layout_width="match_parent"
android:layout_height="match_parent"
@ -18,20 +18,32 @@
app:layout_constraintTop_toTopOf="parent">
<FrameLayout
android:id="@+id/remote_renderer"
android:id="@+id/fullscreen_renderer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"/>
</FrameLayout>
<ImageView
android:id="@+id/remote_recipient"
app:layout_constraintStart_toStartOf="@id/remote_parent"
app:layout_constraintEnd_toEndOf="@id/remote_parent"
app:layout_constraintTop_toTopOf="@id/remote_parent"
app:layout_constraintBottom_toBottomOf="@id/remote_parent"
<org.thoughtcrime.securesms.components.ProfilePictureView
android:id="@+id/userAvatar"
app:layout_constraintStart_toStartOf="@id/fullscreen_renderer_container"
app:layout_constraintEnd_toEndOf="@id/fullscreen_renderer_container"
app:layout_constraintTop_toTopOf="@id/fullscreen_renderer_container"
app:layout_constraintBottom_toBottomOf="@id/fullscreen_renderer_container"
app:layout_constraintVertical_bias="0.4"
android:layout_width="@dimen/extra_large_profile_picture_size"
android:layout_height="@dimen/extra_large_profile_picture_size"/>
android:layout_height="@dimen/extra_large_profile_picture_size"
android:visibility="gone"/>
<org.thoughtcrime.securesms.components.ProfilePictureView
android:id="@+id/contactAvatar"
app:layout_constraintStart_toStartOf="@id/fullscreen_renderer_container"
app:layout_constraintEnd_toEndOf="@id/fullscreen_renderer_container"
app:layout_constraintTop_toTopOf="@id/fullscreen_renderer_container"
app:layout_constraintBottom_toBottomOf="@id/fullscreen_renderer_container"
app:layout_constraintVertical_bias="0.4"
android:layout_width="@dimen/extra_large_profile_picture_size"
android:layout_height="@dimen/extra_large_profile_picture_size" />
<ImageView
android:id="@+id/back_arrow"
@ -71,9 +83,9 @@
android:foregroundGravity="center"
android:visibility="gone"
app:SpinKit_Color="@color/core_white"
app:layout_constraintEnd_toEndOf="@+id/remote_recipient"
app:layout_constraintStart_toStartOf="@+id/remote_recipient"
app:layout_constraintTop_toBottomOf="@id/remote_recipient"
app:layout_constraintEnd_toEndOf="@+id/contactAvatar"
app:layout_constraintStart_toStartOf="@+id/contactAvatar"
app:layout_constraintTop_toBottomOf="@id/contactAvatar"
tools:visibility="visible" />
<TextView
@ -111,6 +123,7 @@
android:layout_height="wrap_content"/>
<FrameLayout
android:id="@+id/floating_renderer_container"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintDimensionRatio="h,9:16"
@ -118,12 +131,21 @@
android:layout_marginVertical="@dimen/massive_spacing"
app:layout_constraintWidth_percent="0.2"
android:layout_height="0dp"
android:layout_width="0dp">
android:layout_width="0dp"
android:background="?backgroundSecondary">
<ImageView
android:id="@+id/videocam_off_icon"
android:src="@drawable/ic_baseline_videocam_off_24"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
app:tint="?android:textColorPrimary"/>
<FrameLayout
android:elevation="8dp"
android:id="@+id/local_renderer"
android:id="@+id/floating_renderer"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
android:layout_height="wrap_content"
android:layout_gravity="center"/>
<com.github.ybq.android.spinkit.SpinKitView
android:id="@+id/local_loading_view"
style="@style/SpinKitView.Large.ThreeBounce"
@ -133,8 +155,20 @@
android:layout_gravity="center"
tools:visibility="visible"
android:visibility="gone" />
</FrameLayout>
<ImageView
android:id="@+id/swap_view_icon"
android:src="@drawable/ic_baseline_screen_rotation_alt_24"
app:layout_constraintTop_toTopOf="@id/floating_renderer_container"
app:layout_constraintEnd_toEndOf="@id/floating_renderer_container"
app:tint="?android:textColorPrimary"
android:layout_marginTop="@dimen/very_small_spacing"
android:layout_marginEnd="@dimen/very_small_spacing"
android:layout_width="14dp"
android:layout_height="14dp"/>
<ImageView
android:id="@+id/endCallButton"
android:background="@drawable/circle_tintable"

View File

@ -10,7 +10,7 @@
android:textAppearance="@style/TextAppearance.MaterialComponents.Body2"
app:chipStrokeWidth="1dp"
app:chipStrokeColor="?elementBorderColor"
app:chipBackgroundColor="?dialog_background_color"
app:chipBackgroundColor="?backgroundSecondary"
app:chipMinTouchTargetSize="0dp"
app:chipStartPadding="4dp"
tools:ignore="TouchTargetSizeCheck"

View File

@ -85,8 +85,7 @@
android:textColor="?unreadIndicatorTextColor"
android:textSize="@dimen/very_small_font_size"
android:textStyle="bold"
tools:text="8"
tools:textColor="?android:textColorPrimary" />
tools:text="8"/>
</RelativeLayout>
@ -115,8 +114,7 @@
android:textColor="?unreadIndicatorTextColor"
android:textSize="@dimen/very_small_font_size"
android:textStyle="bold"
android:text="@"
tools:textColor="?android:textColorPrimary" />
android:text="@" />
</RelativeLayout>

View File

@ -27,17 +27,11 @@
</RelativeLayout>
<ImageView
android:scaleType="centerCrop"
android:id="@+id/singleModeImageView"
android:layout_width="@dimen/medium_profile_picture_size"
android:layout_height="@dimen/medium_profile_picture_size"
android:background="@drawable/profile_picture_view_medium_background" />
<ImageView
android:id="@+id/largeSingleModeImageView"
android:scaleType="centerCrop"
android:layout_width="@dimen/large_profile_picture_size"
android:layout_height="@dimen/large_profile_picture_size"
android:background="@drawable/profile_picture_view_large_background" />
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="true"
android:background="@drawable/profile_picture_view_background" />
</merge>

View File

@ -61,7 +61,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:background="?colorPrimaryDark"
android:background="?backgroundSecondary"
app:SpinKit_Color="?android:textColorPrimary"
android:visibility="gone"/>

View File

@ -186,9 +186,6 @@
<string name="MediaOverviewActivity_Select_all">أختر الكل</string>
<string name="MediaOverviewActivity_collecting_attachments">جارٍ جمع المرفقات...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">رسالة وسائط متعددة</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">تنزيل رسالة الوسائط المتعددة</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">خطأ في تنزيل رسالة الوسائط المتعددة، انقر لاعادة المحاولة</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">إرسال إلى %s</string>
<!-- MediaSendActivity -->

View File

@ -186,9 +186,6 @@
<string name="MediaOverviewActivity_Select_all">أختر الكل</string>
<string name="MediaOverviewActivity_collecting_attachments">جارٍ جمع المرفقات...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">رسالة وسائط متعددة</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">تنزيل رسالة الوسائط المتعددة</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">خطأ في تنزيل رسالة الوسائط المتعددة، انقر لاعادة المحاولة</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">إرسال إلى %s</string>
<!-- MediaSendActivity -->

View File

@ -150,9 +150,6 @@
<string name="MediaOverviewActivity_Select_all">Hamısını seç</string>
<string name="MediaOverviewActivity_collecting_attachments">Qoşmalar yığılır...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Multimedia mesajı</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">MMS mesaj endirilir</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">MMS mesajı endirmə xətası, yenidən sınamaq üçün toxunun</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">%s - göndər</string>
<!-- MediaSendActivity -->

View File

@ -150,9 +150,6 @@
<string name="MediaOverviewActivity_Select_all">Hamısını seç</string>
<string name="MediaOverviewActivity_collecting_attachments">Qoşmalar yığılır...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Multimedia mesajı</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">MMS mesaj endirilir</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">MMS mesajı endirmə xətası, yenidən sınamaq üçün toxunun</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">%s - göndər</string>
<!-- MediaSendActivity -->

View File

@ -96,9 +96,6 @@
<string name="MediaOverviewActivity_Select_all">Pilih semua</string>
<string name="MediaOverviewActivity_collecting_attachments">Mengumpulkan lampiran...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Pesan multimedia</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Mengunduh pesan MMS.</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Gagal saat mengunduh pesan MMS, ketuk untuk mencoba lagi</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Kirim ke %s</string>
<!-- MediaSendActivity -->

View File

@ -96,9 +96,6 @@
<string name="MediaOverviewActivity_Select_all">Pilih semua</string>
<string name="MediaOverviewActivity_collecting_attachments">Mengumpulkan lampiran...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Pesan multimedia</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Mengunduh pesan MMS.</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Gagal saat mengunduh pesan MMS, ketuk untuk mencoba lagi</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Kirim ke %s</string>
<!-- MediaSendActivity -->

View File

@ -142,9 +142,6 @@
<string name="MediaOverviewActivity_Select_all">Избери всичко</string>
<string name="MediaOverviewActivity_collecting_attachments">Събиране на прикачени файлове...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Мултимедийно съобщение</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Изтегляне на MMS съобщение</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Грешка при изтегляне на MMS съобщение, натиснете за да опитате повторно</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Изпрати на %s</string>
<!-- MediaSendActivity -->

View File

@ -142,9 +142,6 @@
<string name="MediaOverviewActivity_Select_all">Избери всичко</string>
<string name="MediaOverviewActivity_collecting_attachments">Събиране на прикачени файлове...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Мултимедийно съобщение</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Изтегляне на MMS съобщение</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Грешка при изтегляне на MMS съобщение, натиснете за да опитате повторно</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Изпрати на %s</string>
<!-- MediaSendActivity -->

View File

@ -147,9 +147,6 @@
<string name="MediaOverviewActivity_Select_all">Selecciona-ho tot</string>
<string name="MediaOverviewActivity_collecting_attachments">S\'estan adjuntant els fitxers...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Missatge multimèdia</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">S\'està baixant el missatge MMS</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">S\'ha produït un error en baixar el missatge MMS. Toqueu per tornar a intentar-ho</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Envia-ho a %s</string>
<!-- MediaSendActivity -->

View File

@ -147,9 +147,6 @@
<string name="MediaOverviewActivity_Select_all">Selecciona-ho tot</string>
<string name="MediaOverviewActivity_collecting_attachments">S\'estan adjuntant els fitxers...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Missatge multimèdia</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">S\'està baixant el missatge MMS</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">S\'ha produït un error en baixar el missatge MMS. Toqueu per tornar a intentar-ho</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Envia-ho a %s</string>
<!-- MediaSendActivity -->

View File

@ -168,9 +168,6 @@
<string name="MediaOverviewActivity_Select_all">Označit vše</string>
<string name="MediaOverviewActivity_collecting_attachments">Shromažďuji přílohy...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Multimediální zpráva</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Stahuji MMS zprávu</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Chyba při stahování MMS zprávy, ťukněte pro opakování</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Poslat %s</string>
<!-- MediaSendActivity -->

View File

@ -168,9 +168,6 @@
<string name="MediaOverviewActivity_Select_all">Označit vše</string>
<string name="MediaOverviewActivity_collecting_attachments">Shromažďuji přílohy...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Multimediální zpráva</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Stahuji MMS zprávu</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Chyba při stahování MMS zprávy, ťukněte pro opakování</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Poslat %s</string>
<!-- MediaSendActivity -->

View File

@ -172,9 +172,6 @@
<string name="MediaOverviewActivity_Select_all">Dewis popeth</string>
<string name="MediaOverviewActivity_collecting_attachments">Casglu atodiadau...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Neges amlgyfrwng</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Llwytho i lawr neges MMS</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Gwall wrth lawrlwytho neges MMS, tapio i geisio eto</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Anfon i %s</string>
<!-- MediaSendActivity -->

View File

@ -172,9 +172,6 @@
<string name="MediaOverviewActivity_Select_all">Dewis popeth</string>
<string name="MediaOverviewActivity_collecting_attachments">Casglu atodiadau...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Neges amlgyfrwng</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Llwytho i lawr neges MMS</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Gwall wrth lawrlwytho neges MMS, tapio i geisio eto</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Anfon i %s</string>
<!-- MediaSendActivity -->

View File

@ -150,9 +150,6 @@
<string name="MediaOverviewActivity_Select_all">Vælg alle</string>
<string name="MediaOverviewActivity_collecting_attachments">Samler vedhæftninger...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Multimedie besked</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Downloader MMS...</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">MMS besked kunne ikke downloades, tap for at prøve igen</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Send til %s</string>
<!-- MediaSendActivity -->

View File

@ -150,9 +150,6 @@
<string name="MediaOverviewActivity_Select_all">Vælg alle</string>
<string name="MediaOverviewActivity_collecting_attachments">Samler vedhæftninger...</string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">Multimedie besked</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">Downloader MMS...</string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">MMS besked kunne ikke downloades, tap for at prøve igen</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">Send til %s</string>
<!-- MediaSendActivity -->

View File

@ -150,9 +150,6 @@
<string name="MediaOverviewActivity_Select_all">Alle auswählen</string>
<string name="MediaOverviewActivity_collecting_attachments">Anhänge werden gesammelt </string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">MMS</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">MMS wird heruntergeladen </string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Fehler beim Herunterladen der MMS. Für erneuten Versuch antippen.</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">An %s senden</string>
<!-- MediaSendActivity -->
@ -569,7 +566,7 @@
<string name="activity_create_private_chat_scan_qr_code_explanation">Scannen Sie den QR-Code eines Benutzers, um eine Session zu starten. QR-Codes finden Sie, indem Sie in den Einstellungen auf das QR-Code-Symbol tippen.</string>
<string name="fragment_enter_public_key_edit_text_hint">Sitzungs-ID oder ONS-Name eingeben</string>
<string name="fragment_enter_public_key_explanation">Benutzer können ihre Account ID freigeben, indem sie in ihren Einstellungen auf \"Account ID freigeben\" tippen oder ihren QR-Code freigeben.</string>
<string name="fragment_enter_public_key_error_message">Bitte überprüfe die Session-ID oder den ONS-Namen und versuche es erneut.</string>
<string name="fragment_enter_public_key_error_message">Bitte überprüfe die Account-ID oder den ONS-Namen und versuche es erneut.</string>
<string name="fragment_scan_qr_code_camera_access_explanation">Session benötigt Kamerazugriff, um die QR-Codes scannen zu können.</string>
<string name="fragment_scan_qr_code_grant_camera_access_button_title">Kamerazugriff gewähren</string>
<string name="activity_create_closed_group_title">Neue geschlossene Gruppe</string>
@ -641,7 +638,7 @@
<string name="dialog_ui_mode_option_day">Tag</string>
<string name="dialog_ui_mode_option_night">Nacht</string>
<string name="dialog_ui_mode_option_system_default">Systemstandard</string>
<string name="activity_conversation_menu_copy_account_id">Session-ID kopieren</string>
<string name="activity_conversation_menu_copy_account_id">Account-ID kopieren</string>
<string name="attachment">Anhang</string>
<string name="attachment_type_voice_message">Sprachnachricht</string>
<string name="details">Details</string>

View File

@ -150,9 +150,6 @@
<string name="MediaOverviewActivity_Select_all">Alle auswählen</string>
<string name="MediaOverviewActivity_collecting_attachments">Anhänge werden gesammelt </string>
<!-- NotificationMmsMessageRecord -->
<string name="NotificationMmsMessageRecord_multimedia_message">MMS</string>
<string name="NotificationMmsMessageRecord_downloading_mms_message">MMS wird heruntergeladen </string>
<string name="NotificationMmsMessageRecord_error_downloading_mms_message">Fehler beim Herunterladen der MMS. Für erneuten Versuch antippen.</string>
<!-- MediaPickerActivity -->
<string name="MediaPickerActivity_send_to">An %s senden</string>
<!-- MediaSendActivity -->
@ -569,7 +566,7 @@
<string name="activity_create_private_chat_scan_qr_code_explanation">Scannen Sie den QR-Code eines Benutzers, um eine Session zu starten. QR-Codes finden Sie, indem Sie in den Einstellungen auf das QR-Code-Symbol tippen.</string>
<string name="fragment_enter_public_key_edit_text_hint">Sitzungs-ID oder ONS-Name eingeben</string>
<string name="fragment_enter_public_key_explanation">Benutzer können ihre Account ID freigeben, indem sie in ihren Einstellungen auf \"Account ID freigeben\" tippen oder ihren QR-Code freigeben.</string>
<string name="fragment_enter_public_key_error_message">Bitte überprüfe die Session-ID oder den ONS-Namen und versuche es erneut.</string>
<string name="fragment_enter_public_key_error_message">Bitte überprüfe die Account-ID oder den ONS-Namen und versuche es erneut.</string>
<string name="fragment_scan_qr_code_camera_access_explanation">Session benötigt Kamerazugriff, um die QR-Codes scannen zu können.</string>
<string name="fragment_scan_qr_code_grant_camera_access_button_title">Kamerazugriff gewähren</string>
<string name="activity_create_closed_group_title">Neue geschlossene Gruppe</string>
@ -641,7 +638,7 @@
<string name="dialog_ui_mode_option_day">Tag</string>
<string name="dialog_ui_mode_option_night">Nacht</string>
<string name="dialog_ui_mode_option_system_default">Systemstandard</string>
<string name="activity_conversation_menu_copy_account_id">Session-ID kopieren</string>
<string name="activity_conversation_menu_copy_account_id">Account-ID kopieren</string>
<string name="attachment">Anhang</string>
<string name="attachment_type_voice_message">Sprachnachricht</string>
<string name="details">Details</string>

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