From c0bf01504921c15e729f05f42a7287bd81351d7e Mon Sep 17 00:00:00 2001 From: ThomasSession Date: Mon, 9 Sep 2024 09:32:07 +1000 Subject: [PATCH] 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. --- .../securesms/ApplicationContext.java | 34 +--- .../securesms/preferences/SettingsActivity.kt | 177 +++++------------ .../preferences/SettingsViewModel.kt | 186 ++++++++++-------- .../thoughtcrime/securesms/ui/AlertDialog.kt | 14 +- .../utilities/ProfilePictureUtilities.kt | 118 ++++++++--- 5 files changed, 255 insertions(+), 274 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index dca939f558..1391499026 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt index 4abfe84f5d..51a634187a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -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 ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt index c09842484b..bedc913109 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -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 = MutableStateFlow( getDefaultAvatarDialogState() ) val avatarDialogState: StateFlow get() = _avatarDialogState + private val _showLoader: MutableStateFlow = MutableStateFlow(false) + val showLoader: StateFlow + get() = _showLoader + + /** + * Refreshes the avatar on the main settings page + */ + private val _refreshAvatar: MutableSharedFlow = MutableSharedFlow() + val refreshAvatar: SharedFlow + 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() + } } \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt index fed01640e4..c4770f2110 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/AlertDialog.kt @@ -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( diff --git a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt index 38d8838c0e..3eace321c8 100644 --- a/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt +++ b/libsession/src/main/java/org/session/libsession/utilities/ProfilePictureUtilities.kt @@ -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 { - val deferred = deferred() - 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) } } \ No newline at end of file