diff --git a/app/build.gradle b/app/build.gradle index 7b9f597c..58c480f3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,6 +9,7 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' apply plugin: 'jacoco' + // Force latest version of Jacoco, initially done to resolve https://github.com/jacoco/jacoco/issues/1155 jacoco.toolVersion = "0.8.7" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 128d4462..b77f5f17 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,9 @@ android:name=".posts.AlbumActivity" android:exported="false" android:theme="@style/AppTheme.ActionBar.Transparent"/> - + + if(uiState.profileLoaded){ + binding.bioEditText.setText(uiState.bio) + binding.nameEditText.setText(uiState.name) + model.changesApplied() + } + binding.progressCard.visibility = if(uiState.loadingProfile || uiState.sendingProfile || uiState.profileSent || uiState.error) View.VISIBLE else View.INVISIBLE + if(uiState.loadingProfile) binding.progressText.setText(R.string.fetching_profile) + else if(uiState.sendingProfile) binding.progressText.setText(R.string.saving_profile) + binding.privateSwitch.isChecked = uiState.privateAccount == true + Glide.with(binding.profilePic).load(uiState.profilePictureUri) + .apply(RequestOptions.circleCropTransform()) + .into(binding.profilePic) + + binding.savingProgressBar.visibility = if(uiState.error || uiState.profileSent) View.GONE + else View.VISIBLE + + if(uiState.profileSent){ + binding.progressText.setText(R.string.profile_saved) + binding.done.visibility = View.VISIBLE + } else { + binding.done.visibility = View.GONE + } + if(uiState.error){ + binding.progressText.setText(R.string.error_profile) + binding.error.visibility = View.VISIBLE + } else binding.error.visibility = View.GONE + + } + } + } + binding.bioEditText.doAfterTextChanged { + model.updateBio(binding.bioEditText.text) + } + binding.nameEditText.doAfterTextChanged { + model.updateName(binding.nameEditText.text) + } + binding.privateSwitch.setOnCheckedChangeListener { _, isChecked -> + model.updatePrivate(isChecked) + } + + binding.progressCard.setOnClickListener { + model.clickedCard() + } + + binding.editButton.setOnClickListener { + val domain = db.userDao().getActiveUser()!!.instance_uri + val url = "$domain/settings/home" + + if(!openUrl(url)) { + Snackbar.make(binding.root, getString(R.string.edit_link_failed), + Snackbar.LENGTH_LONG).show() + } + } + +// binding.changeImageButton.setOnClickListener { +// Intent(Intent.ACTION_GET_CONTENT).apply { +// type = "*/*" +// putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*")) +// action = Intent.ACTION_GET_CONTENT +// addCategory(Intent.CATEGORY_OPENABLE) +// putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) +// uploadImageResultContract.launch( +// Intent.createChooser(this, null) +// ) +// } +// } + } + + private val uploadImageResultContract = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val data: Intent? = result.data + if (result.resultCode == Activity.RESULT_OK && data != null) { + val images: ArrayList = ArrayList() + val clipData = data.clipData + if (clipData != null) { + val count = clipData.itemCount + for (i in 0 until count) { + val imageUri: String = clipData.getItemAt(i).uri.toString() + images.add(imageUri) + } + model.uploadImage(images.first()) + } else if (data.data != null) { + images.add(data.data!!.toString()) + model.uploadImage(images.first()) + } + } + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.edit_profile_menu, menu) + return true + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + if(model.madeChanges()){ + AlertDialog.Builder(binding.root.context).apply { + setMessage(getString(R.string.profile_save_changes)) + setNegativeButton(android.R.string.cancel) { _, _ -> } + setPositiveButton(android.R.string.ok) { _, _ -> super.onBackPressed()} + }.show() + } + else super.onBackPressed() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId){ + R.id.action_apply -> { + model.sendProfile() + return true + } + } + return super.onOptionsItemSelected(item) + } +} diff --git a/app/src/main/java/org/pixeldroid/app/profile/EditProfileViewModel.kt b/app/src/main/java/org/pixeldroid/app/profile/EditProfileViewModel.kt new file mode 100644 index 00000000..f0613ec5 --- /dev/null +++ b/app/src/main/java/org/pixeldroid/app/profile/EditProfileViewModel.kt @@ -0,0 +1,319 @@ +package org.pixeldroid.app.profile + +import android.app.Application +import android.net.Uri +import android.provider.OpenableColumns +import android.text.Editable +import android.util.Log +import android.widget.Toast +import androidx.core.net.toFile +import androidx.core.net.toUri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import org.pixeldroid.app.R +import org.pixeldroid.app.postCreation.ProgressRequestBody +import org.pixeldroid.app.utils.PixelDroidApplication +import org.pixeldroid.app.utils.api.objects.Account +import org.pixeldroid.app.utils.api.objects.Attachment +import org.pixeldroid.app.utils.di.PixelfedAPIHolder +import retrofit2.HttpException +import java.io.File +import java.io.IOException +import java.lang.Exception +import java.net.URI +import javax.inject.Inject + +class EditProfileViewModel(application: Application) : AndroidViewModel(application) { + + @Inject + lateinit var apiHolder: PixelfedAPIHolder + + private val _uiState = MutableStateFlow(EditProfileActivityUiState()) + val uiState: StateFlow = _uiState + + var oldProfile: Account? = null + + init { + (application as PixelDroidApplication).getAppComponent().inject(this) + loadProfile() + } + + private fun loadProfile() { + viewModelScope.launch { + val api = apiHolder.api ?: apiHolder.setToCurrentUser() + try { + val profile = api.verifyCredentials() + if (oldProfile == null) oldProfile = profile + _uiState.update { currentUiState -> + currentUiState.copy( + name = oldProfile?.display_name, + bio = oldProfile?.source?.note, + profilePictureUri = oldProfile?.anyAvatar()?.toUri(), + privateAccount = oldProfile?.locked, + loadingProfile = false, + sendingProfile = false, + profileLoaded = true, + error = false + ) + } + } catch (exception: IOException) { + _uiState.update { currentUiState -> + currentUiState.copy( + sendingProfile = false, + profileSent = false, + loadingProfile = false, + profileLoaded = false, + error = true + ) + } + } catch (exception: HttpException) { + _uiState.update { currentUiState -> + currentUiState.copy( + sendingProfile = false, + profileSent = false, + loadingProfile = false, + profileLoaded = false, + error = true + ) + } + } + } + } + + fun sendProfile() { + val api = apiHolder.api ?: apiHolder.setToCurrentUser() + + val requestBody = + null //MultipartBody.Part.createFormData("avatar", System.currentTimeMillis().toString(), avatarBody) + + _uiState.update { currentUiState -> + currentUiState.copy( + sendingProfile = true, + profileSent = false, + loadingProfile = false, + profileLoaded = false, + error = false + ) + } + + viewModelScope.launch { + with(uiState.value) { + try { + val account = api.updateCredentials( + displayName = name, + note = bio, + locked = privateAccount, + ) + oldProfile = account + _uiState.update { currentUiState -> + currentUiState.copy( + bio = account.note, + name = account.display_name, + profilePictureUri = account.anyAvatar()?.toUri(), + privateAccount = account.locked, + sendingProfile = false, + profileSent = true, + loadingProfile = false, + profileLoaded = true, + error = false + ) + } + } catch (exception: IOException) { + Log.e("TAG", exception.toString()) + _uiState.update { currentUiState -> + currentUiState.copy( + sendingProfile = false, + profileSent = false, + loadingProfile = false, + profileLoaded = false, + error = true + ) + } + } catch (exception: HttpException) { + Log.e("TAG", exception.toString()) + _uiState.update { currentUiState -> + currentUiState.copy( + sendingProfile = false, + profileSent = false, + loadingProfile = false, + profileLoaded = false, + error = true + ) + } + } catch (exception: Exception) { + Log.e("TAG", exception.toString()) + + } + } + } + } + + fun errorShown() { + _uiState.update { currentUiState -> + currentUiState.copy(error = false) + } + } + + fun updateBio(bio: Editable?) { + _uiState.update { currentUiState -> + currentUiState.copy(bio = bio.toString()) + } + } + + fun updateName(name: Editable?) { + _uiState.update { currentUiState -> + currentUiState.copy(name = name.toString()) + } + } + + fun updatePrivate(isChecked: Boolean) { + _uiState.update { currentUiState -> + currentUiState.copy(privateAccount = isChecked) + } + } + + fun changesApplied() { + _uiState.update { currentUiState -> + currentUiState.copy(profileLoaded = false) + } + } + + fun madeChanges(): Boolean = + with(uiState.value) { + oldProfile?.locked != privateAccount + || oldProfile?.display_name != name || oldProfile?.note != bio + } + + fun clickedCard() { + if (uiState.value.error) { + if (!uiState.value.profileLoaded) { + // Load failed + loadProfile() + } else if (uiState.value.profileLoaded) { + // Send failed + sendProfile() + } + } else { + // Dismiss success card + _uiState.update { currentUiState -> + currentUiState.copy(profileSent = false) + } + } + } + + fun uploadImage(image: String) { + //TODO fix + val inputStream = + getApplication().contentResolver.openInputStream(image.toUri()) + ?: return + + val size: Long = + if (image.toUri().scheme == "content") { + getApplication().contentResolver.query( + image.toUri(), + null, + null, + null, + null + ) + ?.use { cursor -> + /* Get the column indexes of the data in the Cursor, + * move to the first row in the Cursor, get the data, + * and display it. + */ + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + cursor.moveToFirst() + cursor.getLong(sizeIndex) + } ?: 0 + } else { + image.toUri().toFile().length() + } + + val imagePart = ProgressRequestBody(inputStream, size, "image/*") + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("avatar", System.currentTimeMillis().toString(), imagePart) + .build() + val sub = imagePart.progressSubject + .subscribeOn(Schedulers.io()) + .subscribe { percentage -> + _uiState.update { currentUiState -> + currentUiState.copy( + uploadProgress = percentage.toInt() + ) + } + } + + var postSub: Disposable? = null + + val api = apiHolder.api ?: apiHolder.setToCurrentUser() + val inter = api.updateProfilePicture(requestBody.parts[0]) + + postSub = inter + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { it: Account -> + Log.e("qsdfqsdfs", it.toString()) + + }, + { e: Throwable -> + _uiState.update { currentUiState -> + currentUiState.copy( + uploadProgress = 0, + uploadingPicture = true, + error = true + ) + } + e.printStackTrace() + postSub?.dispose() + sub.dispose() + }, + { + _uiState.update { currentUiState -> + currentUiState.copy( + uploadProgress = 100, + uploadingPicture = false + ) + } + postSub?.dispose() + sub.dispose() + } + ) + } +} + + +data class EditProfileActivityUiState( + val name: String? = null, + val bio: String? = null, + val profilePictureUri: Uri?= null, + val privateAccount: Boolean? = null, + val loadingProfile: Boolean = true, + val profileLoaded: Boolean = false, + val sendingProfile: Boolean = false, + val profileSent: Boolean = false, + val error: Boolean = false, + val uploadingPicture: Boolean = false, + val uploadProgress: Int = 0, +) + +class EditProfileViewModelFactory(val application: Application) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.getConstructor(Application::class.java).newInstance(application) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/pixeldroid/app/profile/ProfileActivity.kt b/app/src/main/java/org/pixeldroid/app/profile/ProfileActivity.kt index d85b9a08..cedc21fb 100644 --- a/app/src/main/java/org/pixeldroid/app/profile/ProfileActivity.kt +++ b/app/src/main/java/org/pixeldroid/app/profile/ProfileActivity.kt @@ -228,12 +228,8 @@ class ProfileActivity : BaseThemedWithBarActivity() { } private fun onClickEditButton() { - val url = "$domain/settings/home" - - if(!openUrl(url)) { - Snackbar.make(binding.root, getString(R.string.edit_link_failed), - Snackbar.LENGTH_LONG).show() - } + val intent = Intent(this, EditProfileActivity::class.java) + ContextCompat.startActivity(this, intent, null) } private fun onClickFollowers(account: Account?) { diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt index 2d8c11df..14b39e8c 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/PixelfedAPI.kt @@ -295,6 +295,20 @@ interface PixelfedAPI { @Header("Authorization") authorization: String? = null ): Account + //@Multipart + @PATCH("/api/v1/accounts/update_credentials") + suspend fun updateCredentials( + @Query(value = "display_name") displayName: String?, + @Query(value = "note") note: String?, + @Query(value = "locked") locked: Boolean?, + // @Part avatar: MultipartBody.Part?, + ): Account + + @Multipart + @PATCH("/api/v1/accounts/update_credentials") + fun updateProfilePicture( + @Part avatar: MultipartBody.Part? + ): Observable @GET("/api/v1/accounts/{id}/statuses") suspend fun accountPosts( diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Field.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Field.kt index f7ad2d38..711e4e59 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Field.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Field.kt @@ -1,5 +1,12 @@ package org.pixeldroid.app.utils.api.objects import java.io.Serializable +import java.time.Instant -class Field: Serializable +data class Field( + //Required attributes + val name: String?, + val value: String?, + //Optional attributes + val verified_at: Instant? +): Serializable diff --git a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Source.kt b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Source.kt index 8430be4e..0c01533e 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/api/objects/Source.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/api/objects/Source.kt @@ -2,4 +2,16 @@ package org.pixeldroid.app.utils.api.objects import java.io.Serializable -class Source: Serializable +data class Source( + val note: String?, + val fields: List?, + //Nullable attributes + val privacy: Privacy?, + val sensitive: Boolean?, + val language: String?, //ISO 639-1 language two-letter code + val follow_requests_count: Int?, +): Serializable { + enum class Privacy: Serializable { + public, unlisted, private, direct + } +} diff --git a/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt b/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt index 0a646b08..b9a779bb 100644 --- a/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt +++ b/app/src/main/java/org/pixeldroid/app/utils/di/ApplicationComponent.kt @@ -8,6 +8,7 @@ import org.pixeldroid.app.utils.db.AppDatabase import org.pixeldroid.app.utils.BaseFragment import dagger.Component import org.pixeldroid.app.postCreation.PostCreationViewModel +import org.pixeldroid.app.profile.EditProfileViewModel import org.pixeldroid.app.utils.notificationsWorker.NotificationsWorker import javax.inject.Singleton @@ -20,6 +21,7 @@ interface ApplicationComponent { fun inject(feedFragment: BaseFragment) fun inject(notificationsWorker: NotificationsWorker) fun inject(postCreationViewModel: PostCreationViewModel) + fun inject(editProfileViewModel: EditProfileViewModel) val context: Context? val application: Application? diff --git a/app/src/main/res/drawable/done.xml b/app/src/main/res/drawable/done.xml new file mode 100644 index 00000000..399ee2df --- /dev/null +++ b/app/src/main/res/drawable/done.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/layout/activity_edit_profile.xml b/app/src/main/res/layout/activity_edit_profile.xml new file mode 100644 index 00000000..27f23e6c --- /dev/null +++ b/app/src/main/res/layout/activity_edit_profile.xml @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +