mirror of
https://github.com/oxen-io/session-android.git
synced 2024-11-24 18:45:19 +00:00
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:
parent
c6333384da
commit
c0bf015049
@ -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() {
|
||||
|
@ -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
|
||||
)
|
||||
)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user