SES-2651 - New avatar picker

Updated the avatar picker to match the designs. Had to rewrite it in Compose and moved the logic to the VM.
We could moev the whole settings page to compose in another step now that we have the VM set up.
This commit is contained in:
ThomasSession 2024-09-09 09:32:07 +10:00
parent c6333384da
commit c0bf015049
5 changed files with 255 additions and 274 deletions

View File

@ -457,39 +457,7 @@ public class ApplicationContext extends Application implements DefaultLifecycleO
}
private void resubmitProfilePictureIfNeeded() {
// Files expire on the file server after a while, so we simply re-upload the user's profile picture
// at a certain interval to ensure it's always available.
String userPublicKey = TextSecurePreferences.getLocalNumber(this);
if (userPublicKey == null) return;
long now = new Date().getTime();
long lastProfilePictureUpload = TextSecurePreferences.getLastProfilePictureUpload(this);
if (now - lastProfilePictureUpload <= 14 * 24 * 60 * 60 * 1000) return;
ThreadUtils.queue(() -> {
// Don't generate a new profile key here; we do that when the user changes their profile picture
Log.d("Loki-Avatar", "Uploading Avatar Started");
String encodedProfileKey = TextSecurePreferences.getProfileKey(ApplicationContext.this);
try {
// Read the file into a byte array
InputStream inputStream = AvatarHelper.getInputStreamFor(ApplicationContext.this, Address.fromSerialized(userPublicKey));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int count;
byte[] buffer = new byte[1024];
while ((count = inputStream.read(buffer, 0, buffer.length)) != -1) {
baos.write(buffer, 0, count);
}
baos.flush();
byte[] profilePicture = baos.toByteArray();
// Re-upload it
ProfilePictureUtilities.INSTANCE.upload(profilePicture, encodedProfileKey, ApplicationContext.this).success(unit -> {
// Update the last profile picture upload date
TextSecurePreferences.setLastProfilePictureUpload(ApplicationContext.this, new Date().getTime());
Log.d("Loki-Avatar", "Uploading Avatar Finished");
return Unit.INSTANCE;
});
} catch (Exception e) {
Log.e("Loki-Avatar", "Uploading avatar failed.");
}
});
ProfilePictureUtilities.INSTANCE.resubmitProfilePictureIfNeeded(this);
}
private void loadEmojiSearchIndexIfNeeded() {

View File

@ -23,12 +23,12 @@ import androidx.compose.animation.Crossfade
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -37,6 +37,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -44,6 +45,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -51,38 +53,30 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.localbroadcastmanager.content.LocalBroadcastManager
import com.canhub.cropper.CropImageContract
import com.squareup.phrase.Phrase
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
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.ui.alwaysUi
import nl.komponents.kovenant.ui.failUi
import nl.komponents.kovenant.ui.successUi
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.OnionRequestAPI
import org.session.libsession.snode.SnodeAPI
import org.session.libsession.utilities.Address
import org.session.libsession.utilities.ProfileKeyUtil
import org.session.libsession.utilities.ProfilePictureUtilities
import org.session.libsession.utilities.SSKEnvironment.ProfileManagerProtocol
import org.session.libsession.utilities.StringSubstitutionConstants.VERSION_KEY
import org.session.libsession.utilities.TextSecurePreferences
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.Util.SECURE_RANDOM
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.avatar.AvatarSelection
import org.thoughtcrime.securesms.debugmenu.DebugActivity
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.home.PathActivity
import org.thoughtcrime.securesms.messagerequests.MessageRequestsActivity
import org.thoughtcrime.securesms.permissions.Permissions
@ -97,6 +91,7 @@ import org.thoughtcrime.securesms.ui.Divider
import org.thoughtcrime.securesms.ui.GetString
import org.thoughtcrime.securesms.ui.LargeItemButton
import org.thoughtcrime.securesms.ui.LargeItemButtonWithDrawable
import org.thoughtcrime.securesms.ui.components.CircularProgressIndicator
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineButton
import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton
import org.thoughtcrime.securesms.ui.contentDescription
@ -117,8 +112,6 @@ import javax.inject.Inject
class SettingsActivity : PassphraseRequiredActionBarActivity() {
private val TAG = "SettingsActivity"
@Inject
lateinit var configFactory: ConfigFactory
@Inject
lateinit var prefs: TextSecurePreferences
@ -163,19 +156,12 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
binding.avatarDialog.setThemedContent {
if(showAvatarDialog){
AvatarDialogContainer(
saveAvatar = {
//todo TEMPORARY !!!!!!!!!!!!!!!!!!!!
(viewModel.avatarDialogState.value as? TempAvatar)?.let{ updateProfilePicture(it.data) }
},
removeAvatar = ::removeProfilePicture,
saveAvatar = viewModel::saveAvatar,
removeAvatar = viewModel::removeAvatar,
startAvatarSelection = ::startAvatarSelection
)
}
}
}
override fun onStart() {
super.onStart()
binding.run {
profilePictureView.apply {
@ -184,6 +170,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
update()
}
profilePictureView.setOnClickListener {
binding.avatarDialog.isVisible = true
showAvatarDialog = true
}
ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) }
@ -199,6 +186,25 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
binding.composeView.setThemedContent {
Buttons()
}
lifecycleScope.launch {
viewModel.showLoader.collect {
binding.loader.isVisible = it
}
}
lifecycleScope.launch {
viewModel.refreshAvatar.collect {
binding.profilePictureView.recycle()
binding.profilePictureView.update()
}
}
}
override fun onStart() {
super.onStart()
binding.profilePictureView.update()
}
override fun finish() {
@ -289,7 +295,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
} else {
// if we have a network connection then attempt to update the display name
TextSecurePreferences.setProfileName(this, displayName)
val user = configFactory.user
val user = viewModel.getUser()
if (user == null) {
Log.w(TAG, "Cannot update display name - missing user details from configFactory.")
} else {
@ -309,112 +315,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
binding.loader.isVisible = false
return updateWasSuccessful
}
// private fun createAvatarDialog(){
// if (avatarDialog != null) return
//
// avatarDialog = SettingsAvatarDialog(
// userKey = viewModel.hexEncodedPublicKey,
// userName = viewModel.getDisplayName(),
// startAvatarSelection = ::startAvatarSelection,
// saveAvatar = {
// viewModel.temporaryAvatar.value?.let{ updateProfilePicture(it) }
// },
// removeAvatar = ::removeProfilePicture
// )
//
// updateAvatarDialogImage(viewModel.temporaryAvatar.value)
// }
// private fun updateAvatarDialogImage(temporaryAvatar: ByteArray?){
// avatarDialog?.update(
// temporaryAvatar = temporaryAvatar,
// hasUserAvatar = viewModel.hasAvatar()
// )
// }
// 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()
}
val userConfig = configFactory.user
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
prefs.setProfileAvatarId(SECURE_RANDOM.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)
// 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
// region Interaction
@ -605,20 +505,27 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
},
title = stringResource(R.string.profileDisplayPictureSet),
content = {
// custom content that has the displayed images
// main container that control the overall size and adds the rounded bg
Box(
modifier = Modifier
.padding(top = LocalDimensions.current.smallSpacing)
.size(dimensionResource(id = R.dimen.large_profile_picture_size))
.clickable {
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null // the ripple doesn't look nice as a square with the plus icon on top too
) {
startAvatarSelection()
}
.testTag(stringResource(R.string.AccessibilityId_avatarPicker))
.background(
shape = CircleShape,
color = LocalColors.current.backgroundBubbleReceived,
),
contentAlignment = Alignment.Center
) {
// the image content will depend on state type
when(val s = state){
// user avatar
is UserAvatar -> {
@ -646,6 +553,7 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
}
}
// '+' button that sits atop the custom content
Image(
modifier = Modifier
.size(LocalDimensions.current.spacing)
@ -667,11 +575,14 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() {
DialogButtonModel(
text = GetString(R.string.save),
contentDescription = GetString(R.string.AccessibilityId_save),
enabled = state is TempAvatar,
onClick = saveAvatar
),
DialogButtonModel(
text = GetString(R.string.remove),
contentDescription = GetString(R.string.AccessibilityId_remove),
enabled = state is UserAvatar || // can remove is the user has an avatar set
(state is TempAvatar && state.hasAvatar),
onClick = removeAvatar
)
)

View File

@ -2,9 +2,7 @@ package org.thoughtcrime.securesms.preferences
import android.content.Context
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModel
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import com.canhub.cropper.CropImage
import com.canhub.cropper.CropImageView
@ -13,13 +11,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import network.loki.messenger.R
import network.loki.messenger.libsession_util.util.UserPic
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.messaging.MessagingModuleConfiguration
import org.session.libsession.snode.SnodeAPI
@ -32,6 +30,8 @@ import org.session.libsignal.utilities.ExternalStorageUtil.getImageDir
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.NoExternalStorageException
import org.session.libsignal.utilities.Util.SECURE_RANDOM
import org.thoughtcrime.securesms.dependencies.ConfigFactory
import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.TempAvatar
import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints
import org.thoughtcrime.securesms.util.BitmapDecodingException
import org.thoughtcrime.securesms.util.BitmapUtil
@ -44,7 +44,8 @@ import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
val prefs: TextSecurePreferences
private val prefs: TextSecurePreferences,
private val configFactory: ConfigFactory
) : ViewModel() {
private val TAG = "SettingsViewModel"
@ -52,12 +53,25 @@ class SettingsViewModel @Inject constructor(
val hexEncodedPublicKey: String get() = prefs.getLocalNumber() ?: ""
private val userAddress = Address.fromSerialized(hexEncodedPublicKey)
private val _avatarDialogState: MutableStateFlow<AvatarDialogState> = MutableStateFlow(
getDefaultAvatarDialogState()
)
val avatarDialogState: StateFlow<AvatarDialogState>
get() = _avatarDialogState
private val _showLoader: MutableStateFlow<Boolean> = MutableStateFlow(false)
val showLoader: StateFlow<Boolean>
get() = _showLoader
/**
* Refreshes the avatar on the main settings page
*/
private val _refreshAvatar: MutableSharedFlow<Unit> = MutableSharedFlow()
val refreshAvatar: SharedFlow<Unit>
get() = _refreshAvatar.asSharedFlow()
fun getDisplayName(): String =
prefs.getProfileName() ?: truncateIdForDisplay(hexEncodedPublicKey)
@ -77,6 +91,8 @@ class SettingsViewModel @Inject constructor(
fun getTempFile() = tempFile
fun getUser() = configFactory.user
fun onAvatarPicked(result: CropImageView.CropResult) {
when {
result.isSuccessful -> {
@ -93,7 +109,7 @@ class SettingsViewModel @Inject constructor(
// update dialog with temporary avatar (has not been saved/uploaded yet)
_avatarDialogState.value =
AvatarDialogState.TempAvatar(profilePictureToBeUploaded)
AvatarDialogState.TempAvatar(profilePictureToBeUploaded, hasAvatar())
} catch (e: BitmapDecodingException) {
Log.e(TAG, e)
}
@ -111,105 +127,115 @@ class SettingsViewModel @Inject constructor(
}
fun onAvatarDialogDismissed() {
_avatarDialogState.value =getDefaultAvatarDialogState()
_avatarDialogState.value = getDefaultAvatarDialogState()
}
fun getDefaultAvatarDialogState() = if (hasAvatar()) AvatarDialogState.UserAvatar(Address.fromSerialized(hexEncodedPublicKey))
fun getDefaultAvatarDialogState() = if (hasAvatar()) AvatarDialogState.UserAvatar(userAddress)
else AvatarDialogState.NoAvatar
//todo properly close dialog when done and make sure the state is the right one post change
//todo make ripple effect round in dialog avatar picker
//todo link other states, like making sure we show the actual avatar if there's already one
//todo move upload and remove to VM
//todo make buttons in dialog disabled
//todo clean up the classes I made which aren't used now...
fun saveAvatar() {
val tempAvatar = (avatarDialogState.value as? TempAvatar)?.data
?: return Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
sealed class AvatarDialogState() {
object NoAvatar : AvatarDialogState()
data class UserAvatar(val address: Address) : AvatarDialogState()
data class TempAvatar(val data: ByteArray) : AvatarDialogState()
}
// 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()
}
val userConfig = configFactory.user
AvatarHelper.setAvatar(this, Address.fromSerialized(TextSecurePreferences.getLocalNumber(this)!!), profilePicture)
prefs.setProfileAvatarId(SECURE_RANDOM.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)
// 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);
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(context);
if (!haveNetworkConnection) {
Log.w(TAG, "Cannot update profile picture - no network connection.")
Toast.makeText(this@SettingsActivity, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
Toast.makeText(context, 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()
Toast.makeText(context, R.string.profileErrorUpdate, Toast.LENGTH_LONG).show()
}
syncProfilePicture(profilePicture, onFail)
syncProfilePicture(tempAvatar, onFail)
}
private fun removeProfilePicture() {
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(this@SettingsActivity);
fun removeAvatar() {
val haveNetworkConnection = NetworkUtils.haveValidNetworkConnection(context);
if (!haveNetworkConnection) {
Log.w(TAG, "Cannot remove profile picture - no network connection.")
Toast.makeText(this@SettingsActivity, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show()
Toast.makeText(context, 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()
Toast.makeText(context, R.string.profileDisplayPictureRemoveError, Toast.LENGTH_LONG).show()
}
val emptyProfilePicture = ByteArray(0)
syncProfilePicture(emptyProfilePicture, onFail)
}*/
}
// Helper method used by updateProfilePicture and removeProfilePicture to sync it online
private fun syncProfilePicture(profilePicture: ByteArray, onFail: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
_showLoader.value = true
try {
// Grab the profile key and kick of the promise to update the profile picture
val encodedProfileKey = ProfileKeyUtil.generateEncodedProfileKey(context)
ProfilePictureUtilities.upload(profilePicture, encodedProfileKey, context)
// If the online portion of the update succeeded then update the local state
val userConfig = configFactory.user
AvatarHelper.setAvatar(
context,
Address.fromSerialized(TextSecurePreferences.getLocalNumber(context)!!),
profilePicture
)
// When removing the profile picture the supplied ByteArray is empty so we'll clear the local data
if (profilePicture.isEmpty()) {
MessagingModuleConfiguration.shared.storage.clearUserPic()
// update dialog state
_avatarDialogState.value = AvatarDialogState.NoAvatar
} else {
prefs.setProfileAvatarId(SECURE_RANDOM.nextInt())
ProfileKeyUtil.setEncodedProfileKey(context, encodedProfileKey)
// Attempt to grab the details we require to update the profile picture
val url = prefs.getProfilePictureURL()
val profileKey = ProfileKeyUtil.getProfileKey(context)
// 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))
}
// update dialog state
_avatarDialogState.value = AvatarDialogState.UserAvatar(userAddress)
}
if (userConfig != null && userConfig.needsDump()) {
configFactory.persist(userConfig, SnodeAPI.nowWithOffset)
}
ConfigurationMessageUtilities.forceSyncConfigurationNowIfNeeded(context)
} catch (e: Exception){ // If the sync failed then inform the user
Log.d(TAG, "Error syncing avatar: $e")
withContext(Dispatchers.Main) {
onFail()
}
}
// Finally update the main avatar
_refreshAvatar.emit(Unit)
// And remove the loader animation after we've waited for the attempt to succeed or fail
_showLoader.value = false
}
}
sealed class AvatarDialogState() {
object NoAvatar : AvatarDialogState()
data class UserAvatar(val address: Address) : AvatarDialogState()
data class TempAvatar(
val data: ByteArray,
val hasAvatar: Boolean // true if the user has an avatar set already but is in this temp state because they are trying out a new avatar
) : AvatarDialogState()
}
}

View File

@ -58,6 +58,7 @@ class DialogButtonModel(
val contentDescription: GetString = text,
val color: Color = Color.Unspecified,
val dismissOnClick: Boolean = true,
val enabled: Boolean = true,
val onClick: () -> Unit = {},
)
@ -164,7 +165,8 @@ fun AlertDialog(
.fillMaxHeight()
.contentDescription(it.contentDescription())
.weight(1f),
color = it.color
color = it.color,
enabled = it.enabled
) {
it.onClick()
if (it.dismissOnClick) onDismissRequest()
@ -222,16 +224,24 @@ fun DialogButton(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
enabled: Boolean,
onClick: () -> Unit
) {
TextButton(
modifier = modifier,
shape = RectangleShape,
enabled = enabled,
onClick = onClick
) {
val textColor = if(enabled) {
color.takeOrElse { LocalColors.current.text }
} else {
LocalColors.current.disabled
}
Text(
text,
color = color.takeOrElse { LocalColors.current.text },
color = textColor,
style = LocalType.current.large.bold(),
textAlign = TextAlign.Center,
modifier = Modifier.padding(

View File

@ -1,45 +1,111 @@
package org.session.libsession.utilities
import android.content.Context
import nl.komponents.kovenant.Promise
import nl.komponents.kovenant.deferred
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okio.Buffer
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.file_server.FileServerApi
import org.session.libsignal.streams.ProfileCipherOutputStream
import org.session.libsignal.utilities.ProfileAvatarData
import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLastProfilePictureUpload
import org.session.libsession.utilities.TextSecurePreferences.Companion.getLocalNumber
import org.session.libsession.utilities.TextSecurePreferences.Companion.getProfileKey
import org.session.libsession.utilities.TextSecurePreferences.Companion.setLastProfilePictureUpload
import org.session.libsignal.streams.DigestingRequestBody
import org.session.libsignal.streams.ProfileCipherOutputStream
import org.session.libsignal.streams.ProfileCipherOutputStreamFactory
import org.session.libsignal.utilities.Log
import org.session.libsignal.utilities.ProfileAvatarData
import org.session.libsignal.utilities.ThreadUtils.queue
import org.session.libsignal.utilities.retryIfNeeded
import org.session.libsignal.utilities.ThreadUtils
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.*
object ProfilePictureUtilities {
fun upload(profilePicture: ByteArray, encodedProfileKey: String, context: Context): Promise<Unit, Exception> {
val deferred = deferred<Unit, Exception>()
ThreadUtils.queue {
val inputStream = ByteArrayInputStream(profilePicture)
val outputStream = ProfileCipherOutputStream.getCiphertextLength(profilePicture.size.toLong())
val profileKey = ProfileKeyUtil.getProfileKeyFromEncodedString(encodedProfileKey)
val pad = ProfileAvatarData(inputStream, outputStream, "image/jpeg", ProfileCipherOutputStreamFactory(profileKey))
val drb = DigestingRequestBody(pad.data, pad.outputStreamFactory, pad.contentType, pad.dataLength, null)
val b = Buffer()
drb.writeTo(b)
val data = b.readByteArray()
var id: Long = 0
@OptIn(DelicateCoroutinesApi::class)
fun resubmitProfilePictureIfNeeded(context: Context) {
GlobalScope.launch(Dispatchers.IO) {
// Files expire on the file server after a while, so we simply re-upload the user's profile picture
// at a certain interval to ensure it's always available.
val userPublicKey = getLocalNumber(context) ?: return@launch
val now = Date().time
val lastProfilePictureUpload = getLastProfilePictureUpload(context)
if (now - lastProfilePictureUpload <= 14 * 24 * 60 * 60 * 1000) return@launch
// Don't generate a new profile key here; we do that when the user changes their profile picture
Log.d("Loki-Avatar", "Uploading Avatar Started")
val encodedProfileKey =
getProfileKey(context)
try {
id = retryIfNeeded(4) {
FileServerApi.upload(data)
}.get()
// Read the file into a byte array
val inputStream = AvatarHelper.getInputStreamFor(
context,
fromSerialized(userPublicKey)
)
val baos = ByteArrayOutputStream()
var count: Int
val buffer = ByteArray(1024)
while ((inputStream.read(buffer, 0, buffer.size)
.also { count = it }) != -1
) {
baos.write(buffer, 0, count)
}
baos.flush()
val profilePicture = baos.toByteArray()
// Re-upload it
upload(
profilePicture,
encodedProfileKey!!,
context
)
// Update the last profile picture upload date
setLastProfilePictureUpload(
context,
Date().time
)
Log.d("Loki-Avatar", "Uploading Avatar Finished")
} catch (e: Exception) {
deferred.reject(e)
Log.e("Loki-Avatar", "Uploading avatar failed.")
}
TextSecurePreferences.setLastProfilePictureUpload(context, Date().time)
val url = "${FileServerApi.server}/file/$id"
TextSecurePreferences.setProfilePictureURL(context, url)
deferred.resolve(Unit)
}
return deferred.promise
}
suspend fun upload(profilePicture: ByteArray, encodedProfileKey: String, context: Context) {
val inputStream = ByteArrayInputStream(profilePicture)
val outputStream =
ProfileCipherOutputStream.getCiphertextLength(profilePicture.size.toLong())
val profileKey = ProfileKeyUtil.getProfileKeyFromEncodedString(encodedProfileKey)
val pad = ProfileAvatarData(
inputStream,
outputStream,
"image/jpeg",
ProfileCipherOutputStreamFactory(profileKey)
)
val drb = DigestingRequestBody(
pad.data,
pad.outputStreamFactory,
pad.contentType,
pad.dataLength,
null
)
val b = Buffer()
drb.writeTo(b)
val data = b.readByteArray()
var id: Long = 0
// this can throw an error
id = retryIfNeeded(4) {
FileServerApi.upload(data)
}.get()
TextSecurePreferences.setLastProfilePictureUpload(context, Date().time)
val url = "${FileServerApi.server}/file/$id"
TextSecurePreferences.setProfilePictureURL(context, url)
}
}