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