/* Copyright 2018 Conny Duck * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky.viewmodel import android.app.Application import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString import java.io.File import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody private const val HEADER_FILE_NAME = "header.png" private const val AVATAR_FILE_NAME = "avatar.png" internal data class ProfileDataInUi( val displayName: String, val note: String, val locked: Boolean, val fields: List ) class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, private val application: Application, private val instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { val profileData = MutableLiveData>() val avatarData = MutableLiveData() val headerData = MutableLiveData() val saveData = MutableLiveData>() val instanceData: Flow = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) val isChanged = MutableStateFlow(false) private var apiProfileAccount: Account? = null fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { profileData.postValue(Loading()) mastodonApi.accountVerifyCredentials().fold( { profile -> apiProfileAccount = profile profileData.postValue(Success(profile)) }, { profileData.postValue(Error()) } ) } } fun getAvatarUri() = getCacheFileForName(AVATAR_FILE_NAME).toUri() fun getHeaderUri() = getCacheFileForName(HEADER_FILE_NAME).toUri() fun newAvatarPicked() { avatarData.value = getAvatarUri() } fun newHeaderPicked() { headerData.value = getHeaderUri() } internal fun dataChanged(newProfileData: ProfileDataInUi) { isChanged.value = getProfileDiff(apiProfileAccount, newProfileData).hasChanges() } internal fun save(newProfileData: ProfileDataInUi) { if (saveData.value is Loading || profileData.value !is Success) { return } saveData.value = Loading() val diff = getProfileDiff(apiProfileAccount, newProfileData) if (!diff.hasChanges()) { // if nothing has changed, there is no need to make an api call saveData.value = Success() return } viewModelScope.launch { var avatarFileBody: MultipartBody.Part? = null diff.avatarFile?.let { avatarFileBody = MultipartBody.Part.createFormData( "avatar", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull()) ) } var headerFileBody: MultipartBody.Part? = null diff.headerFile?.let { headerFileBody = MultipartBody.Part.createFormData( "header", randomAlphanumericString(12), it.asRequestBody("image/png".toMediaTypeOrNull()) ) } mastodonApi.accountUpdateCredentials( diff.displayName?.toRequestBody(MultipartBody.FORM), diff.note?.toRequestBody(MultipartBody.FORM), diff.locked?.toString()?.toRequestBody(MultipartBody.FORM), avatarFileBody, headerFileBody, diff.field1?.first?.toRequestBody(MultipartBody.FORM), diff.field1?.second?.toRequestBody(MultipartBody.FORM), diff.field2?.first?.toRequestBody(MultipartBody.FORM), diff.field2?.second?.toRequestBody(MultipartBody.FORM), diff.field3?.first?.toRequestBody(MultipartBody.FORM), diff.field3?.second?.toRequestBody(MultipartBody.FORM), diff.field4?.first?.toRequestBody(MultipartBody.FORM), diff.field4?.second?.toRequestBody(MultipartBody.FORM) ).fold( { newAccountData -> saveData.postValue(Success()) eventHub.dispatch(ProfileEditedEvent(newAccountData)) }, { throwable -> saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage())) } ) } } // cache activity state for rotation change internal fun updateProfile(newProfileData: ProfileDataInUi) { if (profileData.value is Success) { val newProfileSource = profileData.value?.data?.source?.copy( note = newProfileData.note, fields = newProfileData.fields ) val newProfile = profileData.value?.data?.copy( displayName = newProfileData.displayName, locked = newProfileData.locked, source = newProfileSource ) profileData.value = Success(newProfile) } } private fun getProfileDiff( oldProfileAccount: Account?, newProfileData: ProfileDataInUi ): DiffProfileData { val displayName = if (oldProfileAccount?.displayName == newProfileData.displayName) { null } else { newProfileData.displayName } val note = if (oldProfileAccount?.source?.note == newProfileData.note) { null } else { newProfileData.note } val locked = if (oldProfileAccount?.locked == newProfileData.locked) { null } else { newProfileData.locked } val avatarFile = if (avatarData.value != null) { getCacheFileForName(AVATAR_FILE_NAME) } else { null } val headerFile = if (headerData.value != null) { getCacheFileForName(HEADER_FILE_NAME) } else { null } // when one field changed, all have to be sent or they unchanged ones would get overridden val allFieldsUnchanged = oldProfileAccount?.source?.fields == newProfileData.fields val field1 = calculateFieldToUpdate(newProfileData.fields.getOrNull(0), allFieldsUnchanged) val field2 = calculateFieldToUpdate(newProfileData.fields.getOrNull(1), allFieldsUnchanged) val field3 = calculateFieldToUpdate(newProfileData.fields.getOrNull(2), allFieldsUnchanged) val field4 = calculateFieldToUpdate(newProfileData.fields.getOrNull(3), allFieldsUnchanged) return DiffProfileData( displayName, note, locked, field1, field2, field3, field4, headerFile, avatarFile ) } private fun calculateFieldToUpdate( newField: StringField?, fieldsUnchanged: Boolean ): Pair? { if (fieldsUnchanged || newField == null) { return null } return Pair( newField.name, newField.value ) } private fun getCacheFileForName(filename: String): File { return File(application.cacheDir, filename) } private data class DiffProfileData( val displayName: String?, val note: String?, val locked: Boolean?, val field1: Pair?, val field2: Pair?, val field3: Pair?, val field4: Pair?, val headerFile: File?, val avatarFile: File? ) { fun hasChanges() = displayName != null || note != null || locked != null || avatarFile != null || headerFile != null || field1 != null || field2 != null || field3 != null || field4 != null } }