From 7db5c5ed76b6857ead83fe403252fe7c11e60fea Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 16 Mar 2022 16:50:37 +0100 Subject: [PATCH 1/5] migrate ProfileController to kotlin Signed-off-by: Andy Scherzinger --- .../talk/controllers/ProfileController.java | 880 ------------------ .../talk/controllers/ProfileController.kt | 808 ++++++++++++++++ 2 files changed, 808 insertions(+), 880 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/controllers/ProfileController.java create mode 100644 app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.java b/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.java deleted file mode 100644 index 54e17069e..000000000 --- a/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.java +++ /dev/null @@ -1,880 +0,0 @@ -/* - * Nextcloud Talk application - * - * @author Tobias Kaminsky - * Copyright (C) 2021 Tobias Kaminsky - * - * 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. - * - * This program 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 this program. If not, see . - */ - -package com.nextcloud.talk.controllers; - -import android.app.Activity; -import android.content.Intent; -import android.content.res.ColorStateList; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.text.Editable; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.EditText; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import com.bluelinelabs.conductor.RouterTransaction; -import com.bluelinelabs.conductor.changehandler.VerticalChangeHandler; -import com.github.dhaval2404.imagepicker.ImagePicker; -import com.nextcloud.talk.R; -import com.nextcloud.talk.api.NcApi; -import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.components.filebrowser.controllers.BrowserController; -import com.nextcloud.talk.components.filebrowser.controllers.BrowserForAvatarController; -import com.nextcloud.talk.controllers.base.BaseController; -import com.nextcloud.talk.models.database.CapabilitiesUtil; -import com.nextcloud.talk.models.database.UserEntity; -import com.nextcloud.talk.models.json.generic.GenericOverall; -import com.nextcloud.talk.models.json.userprofile.Scope; -import com.nextcloud.talk.models.json.userprofile.UserProfileData; -import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall; -import com.nextcloud.talk.models.json.userprofile.UserProfileOverall; -import com.nextcloud.talk.ui.dialog.ScopeDialog; -import com.nextcloud.talk.utils.ApiUtils; -import com.nextcloud.talk.utils.DisplayUtils; -import com.nextcloud.talk.utils.bundle.BundleKeys; -import com.nextcloud.talk.utils.database.user.UserUtils; - -import org.parceler.Parcels; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.LinkedList; -import java.util.List; -import java.util.Locale; - -import javax.inject.Inject; - -import androidx.annotation.ColorInt; -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.RecyclerView; -import autodagger.AutoInjector; -import butterknife.BindView; -import butterknife.ButterKnife; -import io.reactivex.Observer; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; -import okhttp3.MediaType; -import okhttp3.MultipartBody; -import okhttp3.RequestBody; -import okhttp3.ResponseBody; -import retrofit2.Call; -import retrofit2.Callback; -import retrofit2.Response; - -@AutoInjector(NextcloudTalkApplication.class) -public class ProfileController extends BaseController { - private static final String TAG = ProfileController.class.getSimpleName(); - - @Inject - NcApi ncApi; - - @Inject - UserUtils userUtils; - private UserEntity currentUser; - private boolean edit = false; - private RecyclerView recyclerView; - private UserInfoAdapter adapter; - private UserProfileData userInfo; - private ArrayList editableFields = new ArrayList<>(); - - public ProfileController() { - super(); - } - - @NonNull - protected View inflateView(@NonNull LayoutInflater inflater, @NonNull ViewGroup container) { - return inflater.inflate(R.layout.controller_profile, container, false); - } - - @Override - protected void onViewBound(@NonNull View view) { - super.onViewBound(view); - - NextcloudTalkApplication.Companion.getSharedApplication().getComponentApplication().inject(this); - setHasOptionsMenu(true); - } - - @Override - public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - - inflater.inflate(R.menu.menu_profile, menu); - } - - @Override - public void onPrepareOptionsMenu(@NonNull Menu menu) { - super.onPrepareOptionsMenu(menu); - - menu.findItem(R.id.edit).setVisible(editableFields.size() > 0); - - if (edit) { - menu.findItem(R.id.edit).setTitle(R.string.save); - } else { - menu.findItem(R.id.edit).setTitle(R.string.edit); - } - } - - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if (item.getItemId() == R.id.edit) { - if (edit) { - save(); - } - - edit = !edit; - - if (edit) { - item.setTitle(R.string.save); - - getActivity().findViewById(R.id.emptyList).setVisibility(View.GONE); - getActivity().findViewById(R.id.userinfo_list).setVisibility(View.VISIBLE); - - if (CapabilitiesUtil.isAvatarEndpointAvailable(currentUser)) { - // TODO later avatar can also be checked via user fields, for now it is in Talk capability - getActivity().findViewById(R.id.avatar_buttons).setVisibility(View.VISIBLE); - } - - ncApi.getEditableUserProfileFields( - ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()), - ApiUtils.getUrlForUserFields(currentUser.getBaseUrl())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { - // unused atm - } - - @Override - public void onNext(@io.reactivex.annotations.NonNull UserProfileFieldsOverall userProfileFieldsOverall) { - editableFields = userProfileFieldsOverall.getOcs().getData(); - adapter.notifyDataSetChanged(); - } - - @Override - public void onError(@io.reactivex.annotations.NonNull Throwable e) { - Log.e(TAG, "Error loading editable user profile from server", e); - edit = false; - } - - @Override - public void onComplete() { - // unused atm - } - }); - } else { - item.setTitle(R.string.edit); - getActivity().findViewById(R.id.avatar_buttons).setVisibility(View.INVISIBLE); - - if (adapter.filteredDisplayList.isEmpty()) { - getActivity().findViewById(R.id.emptyList).setVisibility(View.VISIBLE); - getActivity().findViewById(R.id.userinfo_list).setVisibility(View.GONE); - } - } - - adapter.notifyDataSetChanged(); - - return true; - } - return super.onOptionsItemSelected(item); - } - - @Override - protected void onAttach(@NonNull View view) { - super.onAttach(view); - - recyclerView = getActivity().findViewById(R.id.userinfo_list); - adapter = new UserInfoAdapter(null, getActivity().getResources().getColor(R.color.colorPrimary), this); - recyclerView.setAdapter(adapter); - recyclerView.setItemViewCacheSize(20); - - currentUser = userUtils.getCurrentUser(); - String credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()); - - getActivity().findViewById(R.id.avatar_upload).setOnClickListener(v -> sendSelectLocalFileIntent()); - getActivity().findViewById(R.id.avatar_choose).setOnClickListener(v -> - showBrowserScreen(BrowserController.BrowserType.DAV_BROWSER)); - - getActivity().findViewById(R.id.avatar_delete).setOnClickListener(v -> - ncApi.deleteAvatar(credentials, ApiUtils.getUrlForTempAvatar(currentUser.getBaseUrl())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - public void onSubscribe(@NonNull Disposable d) { - // unused atm - } - - @Override - public void onNext(@NonNull GenericOverall genericOverall) { - DisplayUtils.loadAvatarImage( - currentUser, - getActivity().findViewById(R.id.avatar_image), - true); - } - - @Override - public void onError(@NonNull Throwable e) { - Toast.makeText(getApplicationContext(), "Error", Toast.LENGTH_LONG).show(); - } - - @Override - public void onComplete() { - // unused atm - } - })); - - ViewCompat.setTransitionName(getActivity().findViewById(R.id.avatar_image), "userAvatar.transitionTag"); - - ncApi.getUserProfile(credentials, ApiUtils.getUrlForUserProfile(currentUser.getBaseUrl())) - .retry(3) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { - // unused atm - } - - @Override - public void onNext(@io.reactivex.annotations.NonNull UserProfileOverall userProfileOverall) { - userInfo = userProfileOverall.getOcs().getData(); - showUserProfile(); - } - - @Override - public void onError(@io.reactivex.annotations.NonNull Throwable e) { - setErrorMessageForMultiList( - getActivity().getString(R.string.userinfo_no_info_headline), - getActivity().getString(R.string.userinfo_error_text), - R.drawable.ic_list_empty_error); - } - - @Override - public void onComplete() { - // unused atm - } - }); - } - - @Override - protected String getTitle() { - return getResources().getString(R.string.nc_profile_personal_info_title); - } - - private void showUserProfile() { - if (getActivity() == null) { - return; - } - - if (currentUser.getBaseUrl() != null) { - ((TextView) getActivity() - .findViewById(R.id.userinfo_baseurl)) - .setText(Uri.parse(currentUser.getBaseUrl()).getHost()); - } - - DisplayUtils.loadAvatarImage(currentUser, getActivity().findViewById(R.id.avatar_image), false); - - if (!TextUtils.isEmpty(userInfo.getDisplayName())) { - ((TextView) getActivity().findViewById(R.id.userinfo_fullName)).setText(userInfo.getDisplayName()); - } - - getActivity().findViewById(R.id.loading_content).setVisibility(View.VISIBLE); - - adapter.setData(createUserInfoDetails(userInfo)); - - if (TextUtils.isEmpty(userInfo.getDisplayName()) && - TextUtils.isEmpty(userInfo.getPhone()) && - TextUtils.isEmpty(userInfo.getEmail()) && - TextUtils.isEmpty(userInfo.getAddress()) && - TextUtils.isEmpty(userInfo.getTwitter()) && - TextUtils.isEmpty(userInfo.getWebsite())) { - - getActivity().findViewById(R.id.userinfo_list).setVisibility(View.GONE); - getActivity().findViewById(R.id.loading_content).setVisibility(View.GONE); - getActivity().findViewById(R.id.emptyList).setVisibility(View.VISIBLE); - - setErrorMessageForMultiList( - getActivity().getString(R.string.userinfo_no_info_headline), - getActivity().getString(R.string.userinfo_no_info_text), R.drawable.ic_user); - } else { - getActivity().findViewById(R.id.emptyList).setVisibility(View.GONE); - - getActivity().findViewById(R.id.loading_content).setVisibility(View.GONE); - getActivity().findViewById(R.id.userinfo_list).setVisibility(View.VISIBLE); - } - - // show edit button - if (CapabilitiesUtil.canEditScopes(currentUser)) { - ncApi.getEditableUserProfileFields(ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()), - ApiUtils.getUrlForUserFields(currentUser.getBaseUrl())) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { - // unused atm - } - - @Override - public void onNext(@io.reactivex.annotations.NonNull UserProfileFieldsOverall userProfileFieldsOverall) { - editableFields = userProfileFieldsOverall.getOcs().getData(); - - getActivity().invalidateOptionsMenu(); - adapter.notifyDataSetChanged(); - } - - @Override - public void onError(@io.reactivex.annotations.NonNull Throwable e) { - Log.e(TAG, "Error loading editable user profile from server", e); - edit = false; - } - - @Override - public void onComplete() { - // unused atm - } - }); - } - } - - private void setErrorMessageForMultiList(String headline, String message, @DrawableRes int errorResource) { - if (getActivity() == null) { - return; - } - - ((TextView) getActivity().findViewById(R.id.empty_list_view_headline)).setText(headline); - ((TextView) getActivity().findViewById(R.id.empty_list_view_text)).setText(message); - ((ImageView) getActivity().findViewById(R.id.empty_list_icon)).setImageResource(errorResource); - - getActivity().findViewById(R.id.empty_list_icon).setVisibility(View.VISIBLE); - getActivity().findViewById(R.id.empty_list_view_text).setVisibility(View.VISIBLE); - getActivity().findViewById(R.id.userinfo_list).setVisibility(View.GONE); - getActivity().findViewById(R.id.loading_content).setVisibility(View.GONE); - } - - private List createUserInfoDetails(UserProfileData userInfo) { - List result = new LinkedList<>(); - - addToList(result, - R.drawable.ic_user, - userInfo.getDisplayName(), - R.string.user_info_displayname, - Field.DISPLAYNAME, - userInfo.getDisplayNameScope()); - addToList(result, - R.drawable.ic_phone, - userInfo.getPhone(), - R.string.user_info_phone, - Field.PHONE, - userInfo.getPhoneScope()); - addToList(result, R.drawable.ic_email, userInfo.getEmail(), R.string.user_info_email, Field.EMAIL, userInfo.getEmailScope()); - addToList(result, - R.drawable.ic_map_marker, - userInfo.getAddress(), - R.string.user_info_address, - Field.ADDRESS, - userInfo.getAddressScope()); - addToList(result, - R.drawable.ic_web, - DisplayUtils.beautifyURL(userInfo.getWebsite()), - R.string.user_info_website, - Field.WEBSITE, - userInfo.getWebsiteScope()); - addToList( - result, - R.drawable.ic_twitter, - DisplayUtils.beautifyTwitterHandle(userInfo.getTwitter()), - R.string.user_info_twitter, - Field.TWITTER, - userInfo.getTwitterScope()); - - return result; - } - - private void addToList(List info, - @DrawableRes int icon, - String text, - @StringRes int contentDescriptionInt, - Field field, - Scope scope) { - info.add(new UserInfoDetailsItem(icon, text, getResources().getString(contentDescriptionInt), field, scope)); - } - - private void save() { - for (UserInfoDetailsItem item : adapter.displayList) { - // Text - if (!item.text.equals(userInfo.getValueByField(item.field))) { - String credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()); - - ncApi.setUserData( - credentials, - ApiUtils.getUrlForUserData(currentUser.getBaseUrl(), currentUser.getUserId()), - item.field.fieldName, - item.text) - .retry(3) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { - // unused atm - } - - @Override - public void onNext(@io.reactivex.annotations.NonNull GenericOverall userProfileOverall) { - Log.d(TAG, "Successfully saved: " + item.text + " as " + item.field); - - if (item.field == Field.DISPLAYNAME) { - ((TextView) getActivity().findViewById(R.id.userinfo_fullName)).setText(item.text); - } - } - - @Override - public void onError(@io.reactivex.annotations.NonNull Throwable e) { - item.text = userInfo.getValueByField(item.field); - Toast.makeText(getApplicationContext(), - String.format(getResources().getString(R.string.failed_to_save), - item.field), - Toast.LENGTH_LONG).show(); - adapter.updateFilteredList(); - adapter.notifyDataSetChanged(); - Log.e(TAG, "Failed to saved: " + item.text + " as " + item.field, e); - } - - @Override - public void onComplete() { - // unused atm - } - }); - } - - // Scope - if (item.scope != userInfo.getScopeByField(item.field)) { - saveScope(item, userInfo); - } - - adapter.updateFilteredList(); - } - } - - private void sendSelectLocalFileIntent() { - Intent intent = ImagePicker.Companion.with(getActivity()) - .galleryOnly() - .crop() - .cropSquare() - .compress(1024) - .maxResultSize(1024, 1024) - .prepareIntent(); - - startActivityForResult(intent, 1); - } - - private void showBrowserScreen(BrowserController.BrowserType browserType) { - Bundle bundle = new Bundle(); - bundle.putParcelable( - BundleKeys.INSTANCE.getKEY_BROWSER_TYPE(), - Parcels.wrap(BrowserController.BrowserType.class, browserType)); - bundle.putParcelable( - BundleKeys.INSTANCE.getKEY_USER_ENTITY(), - Parcels.wrap(UserEntity.class, currentUser)); - bundle.putString(BundleKeys.INSTANCE.getKEY_ROOM_TOKEN(), "123"); - getRouter().pushController(RouterTransaction.with(new BrowserForAvatarController(bundle, this)) - .pushChangeHandler(new VerticalChangeHandler()) - .popChangeHandler(new VerticalChangeHandler())); - } - - public void handleAvatar(String remotePath) { - String uri = currentUser.getBaseUrl() + "/index.php/apps/files/api/v1/thumbnail/512/512/" + - Uri.encode(remotePath, "/"); - - Call downloadCall = ncApi.downloadResizedImage( - ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()), - uri); - - downloadCall.enqueue(new Callback() { - @Override - public void onResponse(@NonNull Call call, @NonNull Response response) { - saveBitmapAndPassToImagePicker(BitmapFactory.decodeStream(response.body().byteStream())); - } - - @Override - public void onFailure(@NonNull Call call, @NonNull Throwable t) { - // unused atm - } - }); - } - - @SuppressWarnings({"IOI_USE_OF_FILE_STREAM_CONSTRUCTORS"}) // only possible with API26 - private void saveBitmapAndPassToImagePicker(Bitmap bitmap) { - File file = null; - try { - file = File.createTempFile("avatar", "png", - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM)); - - try (FileOutputStream out = new FileOutputStream(file)) { - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out); - } catch (IOException e) { - Log.e(TAG, "Error compressing bitmap", e); - } - } catch (IOException e) { - Log.e(TAG, "Error creating temporary avatar image", e); - } - - if (file == null) { - // TODO exception - return; - } - - Intent intent = ImagePicker.Companion.with(getActivity()) - .fileOnly() - .crop() - .cropSquare() - .compress(1024) - .maxResultSize(1024, 1024) - .prepareIntent(); - - intent.putExtra(ImagePicker.EXTRA_FILE, file); - - startActivityForResult(intent, 1); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (resultCode == Activity.RESULT_OK) { - uploadAvatar(ImagePicker.Companion.getFile(data)); - } else if (resultCode == ImagePicker.RESULT_ERROR) { - Toast.makeText(getActivity(), ImagePicker.Companion.getError(data), Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(getActivity(), "Task Cancelled", Toast.LENGTH_SHORT).show(); - } - } - - private void uploadAvatar(File file) { - MultipartBody.Builder builder = new MultipartBody.Builder(); - builder.setType(MultipartBody.FORM); - builder.addFormDataPart("files[]", file.getName(), RequestBody.create(MediaType.parse("image/*"), file)); - - final MultipartBody.Part filePart = MultipartBody.Part.createFormData("files[]", file.getName(), - RequestBody.create(MediaType.parse("image/jpg"), file)); - - // upload file - ncApi.uploadAvatar( - ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()), - ApiUtils.getUrlForTempAvatar(currentUser.getBaseUrl()), - filePart) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { - // unused atm - } - - @Override - public void onNext(@io.reactivex.annotations.NonNull GenericOverall genericOverall) { - DisplayUtils.loadAvatarImage(currentUser, getActivity().findViewById(R.id.avatar_image), true); - } - - @Override - public void onError(@io.reactivex.annotations.NonNull Throwable e) { - Toast.makeText(getApplicationContext(), "Error", Toast.LENGTH_LONG).show(); - Log.e(TAG, "Error uploading avatar", e); - } - - @Override - public void onComplete() { - // unused atm - } - }); - } - - public void saveScope(UserInfoDetailsItem item, UserProfileData userInfo) { - String credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()); - ncApi.setUserData( - credentials, - ApiUtils.getUrlForUserData(currentUser.getBaseUrl(), currentUser.getUserId()), - item.field.getScopeName(), - item.scope.getName()) - .retry(3) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(new Observer() { - @Override - public void onSubscribe(@io.reactivex.annotations.NonNull Disposable d) { - // unused atm - } - - @Override - public void onNext(@io.reactivex.annotations.NonNull GenericOverall userProfileOverall) { - Log.d(TAG, "Successfully saved: " + item.scope + " as " + item.field); - } - - @Override - public void onError(@io.reactivex.annotations.NonNull Throwable e) { - item.scope = userInfo.getScopeByField(item.field); - Log.e(TAG, "Failed to saved: " + item.scope + " as " + item.field, e); - } - - @Override - public void onComplete() { - // unused atm - } - }); - } - - protected static class UserInfoDetailsItem { - @DrawableRes - public int icon; - public String text; - public String hint; - private Field field; - private Scope scope; - - public UserInfoDetailsItem(@DrawableRes int icon, String text, String hint, Field field, Scope scope) { - this.icon = icon; - this.text = text; - this.hint = hint; - this.field = field; - this.scope = scope; - } - } - - public static class UserInfoAdapter extends RecyclerView.Adapter { - protected List displayList; - protected List filteredDisplayList = new LinkedList<>(); - @ColorInt - protected int mTintColor; - private final ProfileController controller; - - public static class ViewHolder extends RecyclerView.ViewHolder { - @BindView(R.id.user_info_detail_container) - protected View container; - @BindView(R.id.icon) - protected ImageView icon; - @BindView(R.id.user_info_edit_text) - protected EditText text; - @BindView(R.id.scope) - protected ImageView scope; - - public ViewHolder(View itemView) { - super(itemView); - ButterKnife.bind(this, itemView); - } - } - - public UserInfoAdapter(List displayList, - @ColorInt int tintColor, - ProfileController controller) { - this.displayList = displayList == null ? new LinkedList<>() : displayList; - mTintColor = tintColor; - this.controller = controller; - } - - public void setData(List displayList) { - this.displayList = displayList == null ? new LinkedList<>() : displayList; - - updateFilteredList(); - - notifyDataSetChanged(); - } - - public void updateFilteredList() { - filteredDisplayList.clear(); - - if (displayList != null) { - for (UserInfoDetailsItem item : displayList) { - if (!TextUtils.isEmpty(item.text)) { - filteredDisplayList.add(item); - } - } - } - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - LayoutInflater inflater = LayoutInflater.from(parent.getContext()); - View view = inflater.inflate(R.layout.user_info_details_table_item, parent, false); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - UserInfoDetailsItem item; - - if (controller.edit) { - item = displayList.get(position); - } else { - item = filteredDisplayList.get(position); - } - - - if (item.scope == null) { - holder.scope.setVisibility(View.GONE); - } else { - holder.scope.setVisibility(View.VISIBLE); - - switch (item.scope) { - case PRIVATE: - case LOCAL: - holder.scope.setImageResource(R.drawable.ic_password); - break; - case FEDERATED: - holder.scope.setImageResource(R.drawable.ic_contacts); - break; - case PUBLISHED: - holder.scope.setImageResource(R.drawable.ic_link); - break; - } - - holder.scope.setContentDescription( - controller.getActivity().getResources().getString( - R.string.scope_toggle_description, - item.hint)); - } - - holder.icon.setImageResource(item.icon); - holder.text.setText(item.text); - holder.text.setHint(item.hint); - holder.text.addTextChangedListener(new TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) { - // unused atm - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - if (controller.edit) { - displayList.get(holder.getAdapterPosition()).text = holder.text.getText().toString(); - } else { - filteredDisplayList.get(holder.getAdapterPosition()).text = holder.text.getText().toString(); - } - } - - @Override - public void afterTextChanged(Editable s) { - // unused atm - } - }); - - holder.icon.setContentDescription(item.hint); - DrawableCompat.setTint(holder.icon.getDrawable(), mTintColor); - - if (!TextUtils.isEmpty(item.text) || controller.edit) { - holder.container.setVisibility(View.VISIBLE); - if (controller.getActivity() != null) { - holder.text.setTextColor(ContextCompat.getColor( - controller.getActivity(), - R.color.conversation_item_header) - ); - } - - if (controller.edit && - controller.editableFields.contains(item.field.toString().toLowerCase(Locale.ROOT))) { - holder.text.setEnabled(true); - holder.text.setFocusableInTouchMode(true); - holder.text.setEnabled(true); - holder.text.setCursorVisible(true); - holder.text.setBackgroundTintList(ColorStateList.valueOf(mTintColor)); - holder.scope.setOnClickListener(v -> new ScopeDialog( - controller.getActivity(), - this, - item.field, - holder.getAdapterPosition()).show()); - holder.scope.setAlpha(0.87f); // active - high emphasis - } else { - holder.text.setEnabled(false); - holder.text.setFocusableInTouchMode(false); - holder.text.setEnabled(false); - holder.text.setCursorVisible(false); - holder.text.setBackgroundTintList(ColorStateList.valueOf(Color.TRANSPARENT)); - holder.scope.setOnClickListener(null); - holder.scope.setAlpha(0.6f); // inactive - medium emphasis - } - } else { - holder.container.setVisibility(View.GONE); - } - } - - @Override - public int getItemCount() { - if (controller.edit) { - return displayList.size(); - } else { - return filteredDisplayList.size(); - } - } - - public void updateScope(int position, Scope scope) { - displayList.get(position).scope = scope; - notifyDataSetChanged(); - } - } - - public enum Field { - EMAIL("email", "emailScope"), - DISPLAYNAME("displayname", "displaynameScope"), - PHONE("phone", "phoneScope"), - ADDRESS("address", "addressScope"), - WEBSITE("website", "websiteScope"), - TWITTER("twitter", "twitterScope"); - - private final String fieldName; - private final String scopeName; - - Field(String fieldName, String scopeName) { - this.fieldName = fieldName; - this.scopeName = scopeName; - } - - public String getFieldName() { - return fieldName; - } - - public String getScopeName() { - return scopeName; - } - } -} diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt new file mode 100644 index 000000000..1aa4d3d40 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt @@ -0,0 +1,808 @@ +/* + * Nextcloud Talk application + * + * @author Tobias Kaminsky + * @author Andy Scherzinger + * Copyright (C) 2022 Andy Scherzinger + * Copyright (C) 2021 Tobias Kaminsky + * + * 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. + * + * This program 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 this program. If not, see . + */ +package com.nextcloud.talk.controllers + +import android.app.Activity +import android.content.Intent +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.os.Environment +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.view.ViewCompat +import androidx.recyclerview.widget.RecyclerView +import autodagger.AutoInjector +import com.bluelinelabs.conductor.RouterTransaction +import com.bluelinelabs.conductor.changehandler.VerticalChangeHandler +import com.github.dhaval2404.imagepicker.ImagePicker +import com.github.dhaval2404.imagepicker.ImagePicker.Companion.getError +import com.github.dhaval2404.imagepicker.ImagePicker.Companion.getFile +import com.github.dhaval2404.imagepicker.ImagePicker.Companion.with +import com.nextcloud.talk.R +import com.nextcloud.talk.api.NcApi +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.components.filebrowser.controllers.BrowserController.BrowserType +import com.nextcloud.talk.components.filebrowser.controllers.BrowserForAvatarController +import com.nextcloud.talk.controllers.base.NewBaseController +import com.nextcloud.talk.controllers.util.viewBinding +import com.nextcloud.talk.databinding.ControllerProfileBinding +import com.nextcloud.talk.databinding.UserInfoDetailsTableItemBinding +import com.nextcloud.talk.models.database.CapabilitiesUtil +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.nextcloud.talk.models.json.userprofile.Scope +import com.nextcloud.talk.models.json.userprofile.UserProfileData +import com.nextcloud.talk.models.json.userprofile.UserProfileFieldsOverall +import com.nextcloud.talk.models.json.userprofile.UserProfileOverall +import com.nextcloud.talk.ui.dialog.ScopeDialog +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_BROWSER_TYPE +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY +import com.nextcloud.talk.utils.database.user.UserUtils +import io.reactivex.Observer +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import org.parceler.Parcels +import retrofit2.Call +import retrofit2.Callback +import retrofit2.Response +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.ArrayList +import java.util.LinkedList +import java.util.Locale +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class ProfileController : NewBaseController(R.layout.controller_profile) { + private val binding: ControllerProfileBinding by viewBinding(ControllerProfileBinding::bind) + + @Inject + lateinit var ncApi: NcApi + + @Inject + lateinit var userUtils: UserUtils + + private var currentUser: UserEntity? = null + private var edit = false + private var adapter: UserInfoAdapter? = null + private var userInfo: UserProfileData? = null + private var editableFields = ArrayList() + + override val title: String + get() = + resources!!.getString(R.string.nc_profile_personal_info_title) + + override fun onViewBound(view: View) { + super.onViewBound(view) + sharedApplication!!.componentApplication.inject(this) + setHasOptionsMenu(true) + } + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_profile, menu) + } + + override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menu.findItem(R.id.edit).isVisible = editableFields.size > 0 + if (edit) { + menu.findItem(R.id.edit).setTitle(R.string.save) + } else { + menu.findItem(R.id.edit).setTitle(R.string.edit) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.edit) { + if (edit) { + save() + } + edit = !edit + if (edit) { + item.setTitle(R.string.save) + binding.emptyList.root.visibility = View.GONE + binding.userinfoList.visibility = View.VISIBLE + if (CapabilitiesUtil.isAvatarEndpointAvailable(currentUser)) { + // TODO later avatar can also be checked via user fields, for now it is in Talk capability + binding.avatarButtons.visibility = View.VISIBLE + } + ncApi.getEditableUserProfileFields( + ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token), + ApiUtils.getUrlForUserFields(currentUser!!.baseUrl) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(userProfileFieldsOverall: UserProfileFieldsOverall) { + editableFields = userProfileFieldsOverall.ocs.data + adapter!!.notifyDataSetChanged() + } + + override fun onError(e: Throwable) { + Log.e(TAG, "Error loading editable user profile from server", e) + edit = false + } + + override fun onComplete() { + // unused atm + } + }) + } else { + item.setTitle(R.string.edit) + binding.avatarButtons.visibility = View.INVISIBLE + if (adapter!!.filteredDisplayList.isEmpty()) { + binding.emptyList.root.visibility = View.VISIBLE + binding.userinfoList.visibility = View.GONE + } + } + adapter!!.notifyDataSetChanged() + return true + } + return super.onOptionsItemSelected(item) + } + + override fun onAttach(view: View) { + super.onAttach(view) + adapter = UserInfoAdapter(null, activity!!.resources.getColor(R.color.colorPrimary), this) + binding.userinfoList.adapter = adapter + binding.userinfoList.setItemViewCacheSize(DEFAULT_CACHE_SIZE) + currentUser = userUtils.currentUser + val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + binding.avatarUpload.setOnClickListener { sendSelectLocalFileIntent() } + binding.avatarChoose.setOnClickListener { showBrowserScreen(BrowserType.DAV_BROWSER) } + binding.avatarDelete.setOnClickListener { + ncApi.deleteAvatar( + credentials, ApiUtils.getUrlForTempAvatar( + currentUser!!.baseUrl + ) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(genericOverall: GenericOverall) { + DisplayUtils.loadAvatarImage( + currentUser, + binding.avatarImage, + true + ) + } + + override fun onError(e: Throwable) { + Toast.makeText(applicationContext, "Error", Toast.LENGTH_LONG).show() + } + + override fun onComplete() { + // unused atm + } + }) + } + ViewCompat.setTransitionName(binding.avatarImage, "userAvatar.transitionTag") + ncApi.getUserProfile(credentials, ApiUtils.getUrlForUserProfile(currentUser!!.baseUrl)) + .retry(DEFAULT_RETRIES) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(userProfileOverall: UserProfileOverall) { + userInfo = userProfileOverall.ocs.data + showUserProfile() + } + + override fun onError(e: Throwable) { + setErrorMessageForMultiList( + activity!!.getString(R.string.userinfo_no_info_headline), + activity!!.getString(R.string.userinfo_error_text), + R.drawable.ic_list_empty_error + ) + } + + override fun onComplete() { + // unused atm + } + }) + } + + private fun isAllEmpty(items: Array): Boolean { + for (item in items) { + if (!TextUtils.isEmpty(item)) { + return false + } + } + + return true + } + + private fun showUserProfile() { + if (activity == null) { + return + } + if (currentUser!!.baseUrl != null) { + binding.userinfoBaseurl.text = Uri.parse(currentUser!!.baseUrl).host + } + DisplayUtils.loadAvatarImage(currentUser, binding.avatarImage, false) + if (!TextUtils.isEmpty(userInfo!!.displayName)) { + binding.userinfoFullName.text = userInfo!!.displayName + } + binding.loadingContent.visibility = View.VISIBLE + adapter!!.setData(createUserInfoDetails(userInfo)) + if (isAllEmpty( + arrayOf( + userInfo!!.displayName, + userInfo!!.phone, + userInfo!!.email, + userInfo!!.address, + userInfo!!.twitter, + userInfo!!.website + ) + ) + ) { + binding.userinfoList.visibility = View.GONE + binding.loadingContent.visibility = View.GONE + binding.emptyList.root.visibility = View.VISIBLE + setErrorMessageForMultiList( + activity!!.getString(R.string.userinfo_no_info_headline), + activity!!.getString(R.string.userinfo_no_info_text), R.drawable.ic_user + ) + } else { + binding.emptyList.root.visibility = View.GONE + binding.loadingContent.visibility = View.GONE + binding.userinfoList.visibility = View.VISIBLE + } + + // show edit button + if (CapabilitiesUtil.canEditScopes(currentUser)) { + ncApi.getEditableUserProfileFields( + ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token), + ApiUtils.getUrlForUserFields(currentUser!!.baseUrl) + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(userProfileFieldsOverall: UserProfileFieldsOverall) { + editableFields = userProfileFieldsOverall.ocs.data + activity!!.invalidateOptionsMenu() + adapter!!.notifyDataSetChanged() + } + + override fun onError(e: Throwable) { + Log.e(TAG, "Error loading editable user profile from server", e) + edit = false + } + + override fun onComplete() { + // unused atm + } + }) + } + } + + private fun setErrorMessageForMultiList(headline: String, message: String, @DrawableRes errorResource: Int) { + if (activity == null) { + return + } + binding.emptyList.emptyListViewHeadline.text = headline + binding.emptyList.emptyListViewText.text = message + binding.emptyList.emptyListIcon.setImageResource(errorResource) + binding.emptyList.emptyListIcon.visibility = View.VISIBLE + binding.emptyList.emptyListViewText.visibility = View.VISIBLE + binding.userinfoList.visibility = View.GONE + binding.loadingContent.visibility = View.GONE + } + + private fun createUserInfoDetails(userInfo: UserProfileData?): List { + val result: MutableList = LinkedList() + result.add( + UserInfoDetailsItem( + R.drawable.ic_user, + userInfo!!.displayName, + resources!!.getString(R.string.user_info_displayname), + Field.DISPLAYNAME, + userInfo.displayNameScope + ) + ) + result.add( + UserInfoDetailsItem( + R.drawable.ic_phone, + userInfo.phone, + resources!!.getString(R.string.user_info_phone), + Field.PHONE, + userInfo.phoneScope + ) + ) + result.add( + UserInfoDetailsItem( + R.drawable.ic_email, + userInfo.email, + resources!!.getString(R.string.user_info_email), + Field.EMAIL, + userInfo.emailScope + ) + ) + result.add( + UserInfoDetailsItem( + R.drawable.ic_map_marker, + userInfo.address, + resources!!.getString(R.string.user_info_address), + Field.ADDRESS, + userInfo.addressScope + ) + ) + result.add( + UserInfoDetailsItem( + R.drawable.ic_web, + DisplayUtils.beautifyURL(userInfo.website), + resources!!.getString(R.string.user_info_website), + Field.WEBSITE, + userInfo.websiteScope + ) + ) + result.add( + UserInfoDetailsItem( + R.drawable.ic_twitter, + DisplayUtils.beautifyTwitterHandle(userInfo.twitter), + resources!!.getString(R.string.user_info_twitter), + Field.TWITTER, + userInfo.twitterScope + ) + ) + return result + } + + private fun save() { + for (item in adapter!!.displayList!!) { + // Text + if (item.text != userInfo!!.getValueByField(item.field)) { + val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + ncApi.setUserData( + credentials, + ApiUtils.getUrlForUserData(currentUser!!.baseUrl, currentUser!!.userId), + item.field.fieldName, + item.text + ) + .retry(DEFAULT_RETRIES) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(userProfileOverall: GenericOverall) { + Log.d(TAG, "Successfully saved: " + item.text + " as " + item.field) + if (item.field == Field.DISPLAYNAME) { + binding.userinfoFullName.text = item.text + } + } + + override fun onError(e: Throwable) { + item.text = userInfo!!.getValueByField(item.field) + Toast.makeText( + applicationContext, String.format( + resources!!.getString(R.string.failed_to_save), + item.field + ), + Toast.LENGTH_LONG + ).show() + adapter!!.updateFilteredList() + adapter!!.notifyDataSetChanged() + Log.e(TAG, "Failed to saved: " + item.text + " as " + item.field, e) + } + + override fun onComplete() { + // unused atm + } + }) + } + + // Scope + if (item.scope != userInfo!!.getScopeByField(item.field)) { + saveScope(item, userInfo) + } + adapter!!.updateFilteredList() + } + } + + private fun sendSelectLocalFileIntent() { + val intent = with(activity!!) + .galleryOnly() + .crop() + .cropSquare() + .compress(MAX_SIZE) + .maxResultSize(MAX_SIZE, MAX_SIZE) + .prepareIntent() + startActivityForResult(intent, 1) + } + + private fun showBrowserScreen(browserType: BrowserType) { + val bundle = Bundle() + bundle.putParcelable( + KEY_BROWSER_TYPE, + Parcels.wrap(BrowserType::class.java, browserType) + ) + bundle.putParcelable( + KEY_USER_ENTITY, + Parcels.wrap(UserEntity::class.java, currentUser) + ) + bundle.putString(KEY_ROOM_TOKEN, "123") + router.pushController( + RouterTransaction.with(BrowserForAvatarController(bundle, this)) + .pushChangeHandler(VerticalChangeHandler()) + .popChangeHandler(VerticalChangeHandler()) + ) + } + + fun handleAvatar(remotePath: String?) { + val uri = currentUser!!.baseUrl + "/index.php/apps/files/api/v1/thumbnail/512/512/" + + Uri.encode(remotePath, "/") + val downloadCall = ncApi.downloadResizedImage( + ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token), + uri + ) + downloadCall.enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + saveBitmapAndPassToImagePicker(BitmapFactory.decodeStream(response.body()!!.byteStream())) + } + + override fun onFailure(call: Call, t: Throwable) { + // unused atm + } + }) + } + + // only possible with API26 + private fun saveBitmapAndPassToImagePicker(bitmap: Bitmap) { + var file: File? = null + try { + file = File.createTempFile( + "avatar", "png", + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM) + ) + try { + FileOutputStream(file).use { out -> bitmap.compress(Bitmap.CompressFormat.PNG, FULL_QUALITY, out) } + } catch (e: IOException) { + Log.e(TAG, "Error compressing bitmap", e) + } + } catch (e: IOException) { + Log.e(TAG, "Error creating temporary avatar image", e) + } + if (file == null) { + // TODO exception + return + } + val intent = with(activity!!) + .fileOnly() + .crop() + .cropSquare() + .compress(MAX_SIZE) + .maxResultSize(MAX_SIZE, MAX_SIZE) + .prepareIntent() + intent.putExtra("extra.file", file) + startActivityForResult(intent, REQUEST_CODE_IMAGE_PICKER) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (resultCode == Activity.RESULT_OK) { + uploadAvatar(getFile(data)) + } else if (resultCode == ImagePicker.RESULT_ERROR) { + Toast.makeText(activity, getError(data), Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(activity, "Task Cancelled", Toast.LENGTH_SHORT).show() + } + } + + private fun uploadAvatar(file: File?) { + val builder = MultipartBody.Builder() + builder.setType(MultipartBody.FORM) + builder.addFormDataPart("files[]", file!!.name, RequestBody.create("image/*".toMediaTypeOrNull(), file)) + val filePart: MultipartBody.Part = MultipartBody.Part.createFormData( + "files[]", file.name, + RequestBody.create("image/jpg".toMediaTypeOrNull(), file) + ) + + // upload file + ncApi.uploadAvatar( + ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token), + ApiUtils.getUrlForTempAvatar(currentUser!!.baseUrl), + filePart + ) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(genericOverall: GenericOverall) { + DisplayUtils.loadAvatarImage(currentUser, binding.avatarImage, true) + } + + override fun onError(e: Throwable) { + Toast.makeText(applicationContext, "Error", Toast.LENGTH_LONG).show() + Log.e(TAG, "Error uploading avatar", e) + } + + override fun onComplete() { + // unused atm + } + }) + } + + fun saveScope(item: UserInfoDetailsItem, userInfo: UserProfileData?) { + val credentials = ApiUtils.getCredentials(currentUser!!.username, currentUser!!.token) + ncApi.setUserData( + credentials, + ApiUtils.getUrlForUserData(currentUser!!.baseUrl, currentUser!!.userId), + item.field.scopeName, + item.scope!!.getName() + ) + .retry(DEFAULT_RETRIES) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + // unused atm + } + + override fun onNext(userProfileOverall: GenericOverall) { + Log.d(TAG, "Successfully saved: " + item.scope + " as " + item.field) + } + + override fun onError(e: Throwable) { + item.scope = userInfo!!.getScopeByField(item.field) + Log.e(TAG, "Failed to saved: " + item.scope + " as " + item.field, e) + } + + override fun onComplete() { + // unused atm + } + }) + } + + class UserInfoDetailsItem( + @field:DrawableRes @param:DrawableRes var icon: Int, + var text: String, + var hint: String, + val field: Field, + var scope: Scope? + ) + + class UserInfoAdapter( + displayList: List?, + @ColorInt tintColor: Int, + controller: ProfileController + ) : RecyclerView.Adapter() { + var displayList: List? + var filteredDisplayList: MutableList = LinkedList() + + @ColorInt + protected var mTintColor: Int + private val controller: ProfileController + + class ViewHolder(val binding: UserInfoDetailsTableItemBinding) : RecyclerView.ViewHolder(binding.root) + + fun setData(displayList: List) { + this.displayList = displayList + updateFilteredList() + notifyDataSetChanged() + } + + fun updateFilteredList() { + filteredDisplayList.clear() + if (displayList != null) { + for (item in displayList!!) { + if (!TextUtils.isEmpty(item.text)) { + filteredDisplayList.add(item) + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val itemBinding = + UserInfoDetailsTableItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(itemBinding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item: UserInfoDetailsItem = if (controller.edit) { + displayList!![position] + } else { + filteredDisplayList[position] + } + + initScopeElements(item, holder) + + holder.binding.icon.setImageResource(item.icon) + initUserInfoEditText(holder, item) + + holder.binding.icon.contentDescription = item.hint + DrawableCompat.setTint(holder.binding.icon.drawable, mTintColor) + if (!TextUtils.isEmpty(item.text) || controller.edit) { + holder.binding.userInfoDetailContainer.visibility = View.VISIBLE + if (controller.activity != null) { + holder.binding.userInfoEditText.setTextColor( + ContextCompat.getColor( + controller.activity!!, + R.color.conversation_item_header + ) + ) + } + if (controller.edit && + controller.editableFields.contains(item.field.toString().toLowerCase(Locale.ROOT)) + ) { + holder.binding.userInfoEditText.isEnabled = true + holder.binding.userInfoEditText.isFocusableInTouchMode = true + holder.binding.userInfoEditText.isEnabled = true + holder.binding.userInfoEditText.isCursorVisible = true + holder.binding.userInfoEditText.backgroundTintList = ColorStateList.valueOf(mTintColor) + holder.binding.scope.setOnClickListener { + ScopeDialog( + controller.activity!!, + this, + item.field, + holder.adapterPosition + ).show() + } + holder.binding.scope.alpha = HIGH_EMPHASIS_ALPHA + } else { + holder.binding.userInfoEditText.isEnabled = false + holder.binding.userInfoEditText.isFocusableInTouchMode = false + holder.binding.userInfoEditText.isEnabled = false + holder.binding.userInfoEditText.isCursorVisible = false + holder.binding.userInfoEditText.backgroundTintList = ColorStateList.valueOf(Color.TRANSPARENT) + holder.binding.scope.setOnClickListener(null) + holder.binding.scope.alpha = MEDIUM_EMPHASIS_ALPHA + } + } else { + holder.binding.userInfoDetailContainer.visibility = View.GONE + } + } + + private fun initUserInfoEditText( + holder: ViewHolder, + item: UserInfoDetailsItem + ) { + holder.binding.userInfoEditText.setText(item.text) + holder.binding.userInfoEditText.hint = item.hint + holder.binding.userInfoEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { + // unused atm + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + if (controller.edit) { + displayList!![holder.adapterPosition].text = holder.binding.userInfoEditText.text.toString() + } else { + filteredDisplayList[holder.adapterPosition].text = + holder.binding.userInfoEditText.text.toString() + } + } + + override fun afterTextChanged(s: Editable) { + // unused atm + } + }) + } + + private fun initScopeElements( + item: UserInfoDetailsItem, + holder: ViewHolder + ) { + if (item.scope == null) { + holder.binding.scope.visibility = View.GONE + } else { + holder.binding.scope.visibility = View.VISIBLE + when (item.scope) { + Scope.PRIVATE, Scope.LOCAL -> holder.binding.scope.setImageResource(R.drawable.ic_password) + Scope.FEDERATED -> holder.binding.scope.setImageResource(R.drawable.ic_contacts) + Scope.PUBLISHED -> holder.binding.scope.setImageResource(R.drawable.ic_link) + } + holder.binding.scope.contentDescription = controller.activity!!.resources.getString( + R.string.scope_toggle_description, + item.hint + ) + } + } + + override fun getItemCount(): Int { + return if (controller.edit) { + displayList!!.size + } else { + filteredDisplayList.size + } + } + + fun updateScope(position: Int, scope: Scope?) { + displayList!![position].scope = scope + notifyDataSetChanged() + } + + init { + this.displayList = displayList ?: LinkedList() + mTintColor = tintColor + this.controller = controller + } + } + + enum class Field(val fieldName: String, val scopeName: String) { + EMAIL("email", "emailScope"), + DISPLAYNAME("displayname", "displaynameScope"), + PHONE("phone", "phoneScope"), + ADDRESS("address", "addressScope"), + WEBSITE("website", "websiteScope"), + TWITTER("twitter", "twitterScope"); + } + + companion object { + private const val TAG: String = "ProfileController" + private const val DEFAULT_CACHE_SIZE: Int = 20 + private const val DEFAULT_RETRIES: Long = 3 + private const val MAX_SIZE: Int = 1024 + private const val REQUEST_CODE_IMAGE_PICKER: Int = 1 + private const val FULL_QUALITY: Int = 100 + private const val HIGH_EMPHASIS_ALPHA: Float = 0.87f + private const val MEDIUM_EMPHASIS_ALPHA: Float = 0.6f + } +} From 766d07d3fa0560b339c5c66e33ab36253352a1ed Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 16 Mar 2022 16:57:13 +0100 Subject: [PATCH 2/5] prevent null-pointer in case remote file listing UI gets closed before the async loading comes back Signed-off-by: Andy Scherzinger --- .../filebrowser/operations/DavListing.java | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/components/filebrowser/operations/DavListing.java b/app/src/main/java/com/nextcloud/talk/components/filebrowser/operations/DavListing.java index 38399344c..dc69a1422 100644 --- a/app/src/main/java/com/nextcloud/talk/components/filebrowser/operations/DavListing.java +++ b/app/src/main/java/com/nextcloud/talk/components/filebrowser/operations/DavListing.java @@ -2,6 +2,8 @@ * Nextcloud Talk application * * @author Mario Danic + * @author Andy Scherzinger + * Copyright (C) 2022 Andy Scherzinger * Copyright (C) 2017-2018 Mario Danic * * This program is free software: you can redistribute it and/or modify @@ -20,20 +22,26 @@ package com.nextcloud.talk.components.filebrowser.operations; -import androidx.annotation.Nullable; +import android.util.Log; + import com.nextcloud.talk.components.filebrowser.interfaces.ListingInterface; import com.nextcloud.talk.components.filebrowser.models.DavResponse; import com.nextcloud.talk.components.filebrowser.webdav.ReadFilesystemOperation; import com.nextcloud.talk.models.database.UserEntity; + +import java.util.concurrent.Callable; + +import androidx.annotation.Nullable; import io.reactivex.Single; import io.reactivex.SingleObserver; +import io.reactivex.annotations.NonNull; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import okhttp3.OkHttpClient; -import java.util.concurrent.Callable; - public class DavListing extends ListingAbstractClass { + private static final String TAG = DavListing.class.getSimpleName(); + private DavResponse davResponse = new DavResponse(); public DavListing(ListingInterface listingInterface) { @@ -50,18 +58,22 @@ public class DavListing extends ListingAbstractClass { }).subscribeOn(Schedulers.io()) .subscribe(new SingleObserver() { @Override - public void onSubscribe(Disposable d) { + public void onSubscribe(@NonNull Disposable d) { } @Override - public void onSuccess(ReadFilesystemOperation readFilesystemOperation) { + public void onSuccess(@NonNull ReadFilesystemOperation readFilesystemOperation) { davResponse = readFilesystemOperation.readRemotePath(); - listingInterface.listingResult(davResponse); + try { + listingInterface.listingResult(davResponse); + } catch (NullPointerException npe) { + Log.i(TAG, "Error loading remote folder - due to view already been terminated", npe); + } } @Override - public void onError(Throwable e) { + public void onError(@NonNull Throwable e) { listingInterface.listingResult(davResponse); } }); From bafa1559db3f4adfdb4e5550459643efb00785fb Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Wed, 16 Mar 2022 18:13:42 +0100 Subject: [PATCH 3/5] reformat kotlin Signed-off-by: Andy Scherzinger --- .../talk/controllers/ProfileController.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt index 1aa4d3d40..c6ee6e012 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ProfileController.kt @@ -203,9 +203,8 @@ class ProfileController : NewBaseController(R.layout.controller_profile) { binding.avatarChoose.setOnClickListener { showBrowserScreen(BrowserType.DAV_BROWSER) } binding.avatarDelete.setOnClickListener { ncApi.deleteAvatar( - credentials, ApiUtils.getUrlForTempAvatar( - currentUser!!.baseUrl - ) + credentials, + ApiUtils.getUrlForTempAvatar(currentUser!!.baseUrl) ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) @@ -285,13 +284,13 @@ class ProfileController : NewBaseController(R.layout.controller_profile) { adapter!!.setData(createUserInfoDetails(userInfo)) if (isAllEmpty( arrayOf( - userInfo!!.displayName, - userInfo!!.phone, - userInfo!!.email, - userInfo!!.address, - userInfo!!.twitter, - userInfo!!.website - ) + userInfo!!.displayName, + userInfo!!.phone, + userInfo!!.email, + userInfo!!.address, + userInfo!!.twitter, + userInfo!!.website + ) ) ) { binding.userinfoList.visibility = View.GONE @@ -439,7 +438,8 @@ class ProfileController : NewBaseController(R.layout.controller_profile) { override fun onError(e: Throwable) { item.text = userInfo!!.getValueByField(item.field) Toast.makeText( - applicationContext, String.format( + applicationContext, + String.format( resources!!.getString(R.string.failed_to_save), item.field ), From a90c6bb20f101cf496cef1fbd492a270e9330dbc Mon Sep 17 00:00:00 2001 From: drone Date: Wed, 16 Mar 2022 18:34:25 +0000 Subject: [PATCH 4/5] Drone: update Lint results to reflect reduced error/warning count [skip ci] Signed-off-by: drone --- scripts/analysis/lint-results.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/analysis/lint-results.txt b/scripts/analysis/lint-results.txt index 8849dd0f0..4cc34e106 100644 --- a/scripts/analysis/lint-results.txt +++ b/scripts/analysis/lint-results.txt @@ -1,2 +1,2 @@ DO NOT TOUCH; GENERATED BY DRONE - Lint Report: 1 error and 168 warnings + Lint Report: 1 error and 164 warnings From 7a0ab36bc37ffba7c9472a4a636e38cc50a687dc Mon Sep 17 00:00:00 2001 From: drone Date: Wed, 16 Mar 2022 18:36:43 +0000 Subject: [PATCH 5/5] Drone: update FindBugs results to reflect reduced error/warning count [skip ci] Signed-off-by: drone --- scripts/analysis/findbugs-results.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/analysis/findbugs-results.txt b/scripts/analysis/findbugs-results.txt index 9ec873d37..bc56e7649 100644 --- a/scripts/analysis/findbugs-results.txt +++ b/scripts/analysis/findbugs-results.txt @@ -1 +1 @@ -438 \ No newline at end of file +431 \ No newline at end of file