diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt index f6f9634ca4..bf19c3cc34 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/AvatarSelection.kt @@ -74,8 +74,9 @@ class AvatarSelection( */ fun startAvatarSelection( includeClear: Boolean, - attemptToIncludeCamera: Boolean - ): File? { + attemptToIncludeCamera: Boolean, + createTempFile: ()->File? + ) { var captureFile: File? = null val hasCameraPermission = ContextCompat .checkSelfPermission( @@ -83,18 +84,11 @@ class AvatarSelection( Manifest.permission.CAMERA ) == PackageManager.PERMISSION_GRANTED if (attemptToIncludeCamera && hasCameraPermission) { - try { - captureFile = File.createTempFile("avatar-capture", ".jpg", getImageDir(activity)) - } catch (e: IOException) { - Log.e("Cannot reserve a temporary avatar capture file.", e) - } catch (e: NoExternalStorageException) { - Log.e("Cannot reserve a temporary avatar capture file.", e) - } + captureFile = createTempFile() } val chooserIntent = createAvatarSelectionIntent(activity, captureFile, includeClear) onPickImage.launch(chooserIntent) - return captureFile } private fun createAvatarSelectionIntent( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index ebf1eacfd4..d445d002cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -572,7 +572,7 @@ class ConversationReactionOverlay : FrameLayout { items += ActionItem(R.attr.menu_save_icon, R.string.save, { handleActionItemClicked(Action.DOWNLOAD) }, - R.string.AccessibilityId_save + R.string.AccessibilityId_saveAttachment ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt index 98332b8661..a100530337 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/MessageDetailActivity.kt @@ -18,9 +18,11 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager @@ -46,6 +48,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi @@ -212,7 +215,15 @@ fun CellMetadata( senderInfo?.let { TitledView(state.fromTitle) { Row { - sender?.let { Avatar(it) } + sender?.let { + Avatar( + recipient = it, + modifier = Modifier + .align(Alignment.CenterVertically) + .size(46.dp) + ) + Spacer(modifier = Modifier.width(LocalDimensions.current.smallSpacing)) + } TitledMonospaceText(it) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt index 286bc74e93..79697252bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -232,7 +231,7 @@ private fun SaveAttachmentWarningDialog( title = context.getString(R.string.warning), text = context.resources.getString(R.string.attachmentsWarning), buttons = listOf( - DialogButtonModel(GetString(R.string.save), GetString(R.string.AccessibilityId_save), color = LocalColors.current.danger, onClick = onAccepted), + DialogButtonModel(GetString(R.string.save), GetString(R.string.AccessibilityId_saveAttachment), color = LocalColors.current.danger, onClick = onAccepted), DialogButtonModel(GetString(android.R.string.cancel), GetString(R.string.AccessibilityId_cancel), dismissOnClick = true) ) ) 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 75abce49a9..4abfe84f5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsActivity.kt @@ -6,6 +6,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.graphics.BitmapFactory import android.net.Uri import android.os.Bundle import android.os.Parcelable @@ -13,38 +14,52 @@ import android.util.SparseArray import android.view.ActionMode import android.view.Menu import android.view.MenuItem -import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.compose.animation.Crossfade +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable 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 +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +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.CropImage 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.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 @@ -53,7 +68,6 @@ import nl.komponents.kovenant.ui.alwaysUi import nl.komponents.kovenant.ui.failUi import nl.komponents.kovenant.ui.successUi import org.session.libsession.avatars.AvatarHelper -import org.session.libsession.avatars.ProfileContactPhoto import org.session.libsession.messaging.MessagingModuleConfiguration import org.session.libsession.snode.OnionRequestAPI import org.session.libsession.snode.SnodeAPI @@ -63,34 +77,36 @@ 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.libsession.utilities.recipients.Recipient -import org.session.libsession.utilities.truncateIdForDisplay 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.components.ProfilePictureView 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 +import org.thoughtcrime.securesms.preferences.SettingsViewModel.AvatarDialogState.* import org.thoughtcrime.securesms.preferences.appearance.AppearanceSettingsActivity -import org.thoughtcrime.securesms.profiles.ProfileMediaConstraints import org.thoughtcrime.securesms.recoverypassword.RecoveryPasswordActivity -import org.thoughtcrime.securesms.showSessionDialog +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.Avatar import org.thoughtcrime.securesms.ui.Cell +import org.thoughtcrime.securesms.ui.DialogButtonModel 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.PrimaryOutlineButton import org.thoughtcrime.securesms.ui.components.PrimaryOutlineCopyButton import org.thoughtcrime.securesms.ui.contentDescription import org.thoughtcrime.securesms.ui.setThemedContent +import org.thoughtcrime.securesms.ui.theme.LocalColors import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionColorsParameterProvider +import org.thoughtcrime.securesms.ui.theme.ThemeColors import org.thoughtcrime.securesms.ui.theme.dangerButtonColors -import org.thoughtcrime.securesms.util.BitmapDecodingException -import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.NetworkUtils import org.thoughtcrime.securesms.util.push @@ -106,41 +122,14 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { @Inject lateinit var prefs: TextSecurePreferences + private val viewModel: SettingsViewModel by viewModels() + private lateinit var binding: ActivitySettingsBinding private var displayNameEditActionMode: ActionMode? = null set(value) { field = value; handleDisplayNameEditActionModeChanged() } - private var tempFile: File? = null - - private val hexEncodedPublicKey: String get() = TextSecurePreferences.getLocalNumber(this)!! private val onAvatarCropped = registerForActivityResult(CropImageContract()) { result -> - when { - result.isSuccessful -> { - Log.i(TAG, result.getUriFilePath(this).toString()) - - lifecycleScope.launch(Dispatchers.IO) { - try { - val profilePictureToBeUploaded = - BitmapUtil.createScaledBytes( - this@SettingsActivity, - result.getUriFilePath(this@SettingsActivity).toString(), - ProfileMediaConstraints() - ).bitmap - launch(Dispatchers.Main) { - updateProfilePicture(profilePictureToBeUploaded) - } - } catch (e: BitmapDecodingException) { - Log.e(TAG, e) - } - } - } - result is CropImage.CancelledResult -> { - Log.i(TAG, "Cropping image was cancelled by the user") - } - else -> { - Log.e(TAG, "Cropping image failed") - } - } + viewModel.onAvatarPicked(result) } private val onPickImage = registerForActivityResult( @@ -149,12 +138,14 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val outputFile = Uri.fromFile(File(cacheDir, "cropped")) - val inputFile: Uri? = result.data?.data ?: tempFile?.let(Uri::fromFile) + val inputFile: Uri? = result.data?.data ?: viewModel.getTempFile()?.let(Uri::fromFile) cropImage(inputFile, outputFile) } private val avatarSelection = AvatarSelection(this, onAvatarCropped, onPickImage) + private var showAvatarDialog: Boolean by mutableStateOf(false) + companion object { private const val SCROLL_STATE = "SCROLL_STATE" } @@ -167,17 +158,37 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { // set the toolbar icon to a close icon supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_baseline_close_24) + + // set the compose dialog content + binding.avatarDialog.setThemedContent { + if(showAvatarDialog){ + AvatarDialogContainer( + saveAvatar = { + //todo TEMPORARY !!!!!!!!!!!!!!!!!!!! + (viewModel.avatarDialogState.value as? TempAvatar)?.let{ updateProfilePicture(it.data) } + }, + removeAvatar = ::removeProfilePicture, + startAvatarSelection = ::startAvatarSelection + ) + } + } } override fun onStart() { super.onStart() binding.run { - setupProfilePictureView(profilePictureView) - profilePictureView.setOnClickListener { showEditProfilePictureUI() } + profilePictureView.apply { + publicKey = viewModel.hexEncodedPublicKey + displayName = viewModel.getDisplayName() + update() + } + profilePictureView.setOnClickListener { + showAvatarDialog = true + } ctnGroupNameSection.setOnClickListener { startActionMode(DisplayNameEditActionModeCallback()) } - btnGroupNameDisplay.text = getDisplayName() - publicKeyTextView.text = hexEncodedPublicKey + btnGroupNameDisplay.text = viewModel.getDisplayName() + publicKeyTextView.text = viewModel.hexEncodedPublicKey val gitCommitFirstSixChars = BuildConfig.GIT_HASH.take(6) val environment: String = if(BuildConfig.BUILD_TYPE == "release") "" else " - ${prefs.getEnvironment().label}" val versionDetails = " ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE} - $gitCommitFirstSixChars) $environment" @@ -195,17 +206,6 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_bottom) } - private fun getDisplayName(): String = - TextSecurePreferences.getProfileName(this) ?: truncateIdForDisplay(hexEncodedPublicKey) - - private fun setupProfilePictureView(view: ProfilePictureView) { - view.apply { - publicKey = hexEncodedPublicKey - displayName = getDisplayName() - update() - } - } - override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) val scrollBundle = SparseArray() @@ -310,6 +310,29 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { 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 @@ -415,39 +438,16 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { return updateDisplayName(displayName) } - private fun showEditProfilePictureUI() { - showSessionDialog { - title(R.string.profileDisplayPictureSet) - view(R.layout.dialog_change_avatar) - - // Note: This is the only instance in a dialog where the "Save" button is not a `dangerButton` - button(R.string.save) { startAvatarSelection() } - - if (prefs.getProfileAvatarId() != 0) { - button(R.string.remove) { removeProfilePicture() } - } - cancelButton() - }.apply { - val profilePic = findViewById(R.id.profile_picture_view) - ?.also(::setupProfilePictureView) - - val pictureIcon = findViewById(R.id.ic_pictures) - - val recipient = Recipient.from(context, Address.fromSerialized(hexEncodedPublicKey), false) - - val photoSet = (recipient.contactPhoto as ProfileContactPhoto).avatarObject !in setOf("0", "") - - profilePic?.isVisible = photoSet - pictureIcon?.isVisible = !photoSet - } - } - private fun startAvatarSelection() { // Ask for an optional camera permission. Permissions.with(this) .request(Manifest.permission.CAMERA) .onAnyResult { - tempFile = avatarSelection.startAvatarSelection( false, true) + avatarSelection.startAvatarSelection( + includeClear = false, + attemptToIncludeCamera = true, + createTempFile = viewModel::createTempFile + ) } .execute() } @@ -574,6 +574,124 @@ class SettingsActivity : PassphraseRequiredActionBarActivity() { } } } + + @Composable + fun AvatarDialogContainer( + startAvatarSelection: ()->Unit, + saveAvatar: ()->Unit, + removeAvatar: ()->Unit + ){ + val state by viewModel.avatarDialogState.collectAsState() + + AvatarDialog( + state = state, + startAvatarSelection = startAvatarSelection, + saveAvatar = saveAvatar, + removeAvatar = removeAvatar + ) + } + + @Composable + fun AvatarDialog( + state: SettingsViewModel.AvatarDialogState, + startAvatarSelection: ()->Unit, + saveAvatar: ()->Unit, + removeAvatar: ()->Unit + ){ + AlertDialog( + onDismissRequest = { + viewModel.onAvatarDialogDismissed() + showAvatarDialog = false + }, + title = stringResource(R.string.profileDisplayPictureSet), + content = { + Box( + modifier = Modifier + .padding(top = LocalDimensions.current.smallSpacing) + + .size(dimensionResource(id = R.dimen.large_profile_picture_size)) + .clickable { + startAvatarSelection() + } + .background( + shape = CircleShape, + color = LocalColors.current.backgroundBubbleReceived, + ), + contentAlignment = Alignment.Center + ) { + when(val s = state){ + // user avatar + is UserAvatar -> { + Avatar(userAddress = s.address) + } + + // temporary image + is TempAvatar -> { + Image( + modifier = Modifier.size(dimensionResource(id = R.dimen.large_profile_picture_size)) + .clip(shape = CircleShape,), + bitmap = BitmapFactory.decodeByteArray(s.data, 0, s.data.size).asImageBitmap(), + contentDescription = null + ) + } + + // empty state + else -> { + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_pictures), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColors.current.textSecondary) + ) + } + } + + Image( + modifier = Modifier + .size(LocalDimensions.current.spacing) + .background( + shape = CircleShape, + color = LocalColors.current.primary + ) + .padding(LocalDimensions.current.xxxsSpacing) + .align(Alignment.BottomEnd) + , + painter = painterResource(id = R.drawable.ic_plus), + contentDescription = null, + colorFilter = ColorFilter.tint(Color.Black) + ) + } + }, + showCloseButton = true, // display the 'x' button + buttons = listOf( + DialogButtonModel( + text = GetString(R.string.save), + contentDescription = GetString(R.string.AccessibilityId_save), + onClick = saveAvatar + ), + DialogButtonModel( + text = GetString(R.string.remove), + contentDescription = GetString(R.string.AccessibilityId_remove), + onClick = removeAvatar + ) + ) + ) + } + + @Preview + @Composable + fun PreviewAvatarDialog( + @PreviewParameter(SessionColorsParameterProvider::class) colors: ThemeColors + ){ + PreviewTheme(colors) { + AvatarDialog( + state = NoAvatar, + startAvatarSelection = {}, + saveAvatar = {}, + removeAvatar = {} + ) + } + } } private fun Context.hasPaths(): Flow = LocalBroadcastManager.getInstance(this).hasPaths() diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt new file mode 100644 index 0000000000..c09842484b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/SettingsViewModel.kt @@ -0,0 +1,215 @@ +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 +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +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 +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ProfileKeyUtil +import org.session.libsession.utilities.ProfilePictureUtilities +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.truncateIdForDisplay +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.profiles.ProfileMediaConstraints +import org.thoughtcrime.securesms.util.BitmapDecodingException +import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities +import org.thoughtcrime.securesms.util.NetworkUtils +import java.io.File +import java.io.IOException +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + @ApplicationContext private val context: Context, + val prefs: TextSecurePreferences +) : ViewModel() { + private val TAG = "SettingsViewModel" + + private var tempFile: File? = null + + val hexEncodedPublicKey: String get() = prefs.getLocalNumber() ?: "" + + private val _avatarDialogState: MutableStateFlow = MutableStateFlow( + getDefaultAvatarDialogState() + ) + val avatarDialogState: StateFlow + get() = _avatarDialogState + + fun getDisplayName(): String = + prefs.getProfileName() ?: truncateIdForDisplay(hexEncodedPublicKey) + + fun hasAvatar() = prefs.getProfileAvatarId() != 0 + + fun createTempFile(): File? { + try { + tempFile = File.createTempFile("avatar-capture", ".jpg", getImageDir(context)) + } catch (e: IOException) { + Log.e("Cannot reserve a temporary avatar capture file.", e) + } catch (e: NoExternalStorageException) { + Log.e("Cannot reserve a temporary avatar capture file.", e) + } + + return tempFile + } + + fun getTempFile() = tempFile + + fun onAvatarPicked(result: CropImageView.CropResult) { + when { + result.isSuccessful -> { + Log.i(TAG, result.getUriFilePath(context).toString()) + + viewModelScope.launch(Dispatchers.IO) { + try { + val profilePictureToBeUploaded = + BitmapUtil.createScaledBytes( + context, + result.getUriFilePath(context).toString(), + ProfileMediaConstraints() + ).bitmap + + // update dialog with temporary avatar (has not been saved/uploaded yet) + _avatarDialogState.value = + AvatarDialogState.TempAvatar(profilePictureToBeUploaded) + } catch (e: BitmapDecodingException) { + Log.e(TAG, e) + } + } + } + + result is CropImage.CancelledResult -> { + Log.i(TAG, "Cropping image was cancelled by the user") + } + + else -> { + Log.e(TAG, "Cropping image failed") + } + } + } + + fun onAvatarDialogDismissed() { + _avatarDialogState.value =getDefaultAvatarDialogState() + } + + fun getDefaultAvatarDialogState() = if (hasAvatar()) AvatarDialogState.UserAvatar(Address.fromSerialized(hexEncodedPublicKey)) + 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... + + 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); + 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) + }*/ +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index d04d73fda1..355d74947d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -65,6 +65,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import network.loki.messenger.R +import org.session.libsession.utilities.Address import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.conversation.disappearingmessages.ui.OptionsCardData @@ -399,22 +400,31 @@ fun Divider(modifier: Modifier = Modifier, startIndent: Dp = 0.dp) { ) } +//TODO This component should be fully rebuilt in Compose at some point ~~ @Composable -fun RowScope.Avatar(recipient: Recipient) { - Box( - modifier = Modifier - .width(60.dp) - .align(Alignment.CenterVertically) - ) { - AndroidView( - factory = { - ProfilePictureView(it).apply { update(recipient) } - }, - modifier = Modifier - .width(46.dp) - .height(46.dp) - ) - } +fun Avatar( + recipient: Recipient, + modifier: Modifier = Modifier +) { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(recipient) } + }, + modifier = modifier + ) +} + +@Composable +fun Avatar( + userAddress: Address, + modifier: Modifier = Modifier +) { + AndroidView( + factory = { + ProfilePictureView(it).apply { update(userAddress) } + }, + modifier = modifier + ) } @Composable diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml index cd5e2e7cef..3bfc9c65b0 100644 --- a/app/src/main/res/layout/activity_settings.xml +++ b/app/src/main/res/layout/activity_settings.xml @@ -139,4 +139,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_change_avatar.xml b/app/src/main/res/layout/dialog_change_avatar.xml deleted file mode 100644 index f240f81bbc..0000000000 --- a/app/src/main/res/layout/dialog_change_avatar.xml +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/content-descriptions/src/main/res/values/strings.xml b/content-descriptions/src/main/res/values/strings.xml index 4dae1db1c5..5eaab10ae0 100644 --- a/content-descriptions/src/main/res/values/strings.xml +++ b/content-descriptions/src/main/res/values/strings.xml @@ -43,6 +43,7 @@ Details Pin User settings + Image picker Search icon Blocked contacts @@ -122,7 +123,7 @@ Message body Message sent status: Sent Reply to message - Save attachment + Save attachment Select Voice message Delivered @@ -157,5 +158,7 @@ Close Dialog Expand Media message + Save + Remove \ No newline at end of file