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() { private void resubmitProfilePictureIfNeeded() {
// Files expire on the file server after a while, so we simply re-upload the user's profile picture ProfilePictureUtilities.INSTANCE.resubmitProfilePictureIfNeeded(this);
// 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.");
}
});
} }
private void loadEmojiSearchIndexIfNeeded() { private void loadEmojiSearchIndexIfNeeded() {

View File

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

View File

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

View File

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

View File

@ -1,45 +1,111 @@
package org.session.libsession.utilities package org.session.libsession.utilities
import android.content.Context import android.content.Context
import nl.komponents.kovenant.Promise import kotlinx.coroutines.DelicateCoroutinesApi
import nl.komponents.kovenant.deferred import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okio.Buffer import okio.Buffer
import org.session.libsession.avatars.AvatarHelper
import org.session.libsession.messaging.file_server.FileServerApi import org.session.libsession.messaging.file_server.FileServerApi
import org.session.libsignal.streams.ProfileCipherOutputStream import org.session.libsession.utilities.Address.Companion.fromSerialized
import org.session.libsignal.utilities.ProfileAvatarData 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.DigestingRequestBody
import org.session.libsignal.streams.ProfileCipherOutputStream
import org.session.libsignal.streams.ProfileCipherOutputStreamFactory 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.retryIfNeeded
import org.session.libsignal.utilities.ThreadUtils
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.* import java.util.*
object ProfilePictureUtilities { object ProfilePictureUtilities {
fun upload(profilePicture: ByteArray, encodedProfileKey: String, context: Context): Promise<Unit, Exception> { @OptIn(DelicateCoroutinesApi::class)
val deferred = deferred<Unit, Exception>() fun resubmitProfilePictureIfNeeded(context: Context) {
ThreadUtils.queue { 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 {
// 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) {
Log.e("Loki-Avatar", "Uploading avatar failed.")
}
}
}
suspend fun upload(profilePicture: ByteArray, encodedProfileKey: String, context: Context) {
val inputStream = ByteArrayInputStream(profilePicture) val inputStream = ByteArrayInputStream(profilePicture)
val outputStream = ProfileCipherOutputStream.getCiphertextLength(profilePicture.size.toLong()) val outputStream =
ProfileCipherOutputStream.getCiphertextLength(profilePicture.size.toLong())
val profileKey = ProfileKeyUtil.getProfileKeyFromEncodedString(encodedProfileKey) val profileKey = ProfileKeyUtil.getProfileKeyFromEncodedString(encodedProfileKey)
val pad = ProfileAvatarData(inputStream, outputStream, "image/jpeg", ProfileCipherOutputStreamFactory(profileKey)) val pad = ProfileAvatarData(
val drb = DigestingRequestBody(pad.data, pad.outputStreamFactory, pad.contentType, pad.dataLength, null) inputStream,
outputStream,
"image/jpeg",
ProfileCipherOutputStreamFactory(profileKey)
)
val drb = DigestingRequestBody(
pad.data,
pad.outputStreamFactory,
pad.contentType,
pad.dataLength,
null
)
val b = Buffer() val b = Buffer()
drb.writeTo(b) drb.writeTo(b)
val data = b.readByteArray() val data = b.readByteArray()
var id: Long = 0 var id: Long = 0
try {
// this can throw an error
id = retryIfNeeded(4) { id = retryIfNeeded(4) {
FileServerApi.upload(data) FileServerApi.upload(data)
}.get() }.get()
} catch (e: Exception) {
deferred.reject(e)
}
TextSecurePreferences.setLastProfilePictureUpload(context, Date().time) TextSecurePreferences.setLastProfilePictureUpload(context, Date().time)
val url = "${FileServerApi.server}/file/$id" val url = "${FileServerApi.server}/file/$id"
TextSecurePreferences.setProfilePictureURL(context, url) TextSecurePreferences.setProfilePictureURL(context, url)
deferred.resolve(Unit)
}
return deferred.promise
} }
} }