Compose: language selection

This commit is contained in:
Grishka
2023-05-12 22:21:21 +03:00
parent 89501271ce
commit 15883f2138
9 changed files with 512 additions and 32 deletions

View File

@@ -1,19 +1,29 @@
package org.joinmastodon.android.api.session;
import android.util.Log;
import org.joinmastodon.android.api.CacheController;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.PushSubscriptionManager;
import org.joinmastodon.android.api.StatusInteractionController;
import org.joinmastodon.android.api.requests.accounts.GetPreferences;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.model.Token;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
public class AccountSession{
private static final String TAG="AccountSession";
public Token token;
public Account self;
public String domain;
@@ -29,6 +39,7 @@ public class AccountSession{
public List<Filter> wordFilters=new ArrayList<>();
public String pushAccountID;
public AccountActivationInfo activationInfo;
public Preferences preferences;
private transient MastodonAPIController apiController;
private transient StatusInteractionController statusInteractionController;
private transient CacheController cacheController;
@@ -77,4 +88,22 @@ public class AccountSession{
public String getFullUsername(){
return '@'+self.username+'@'+domain;
}
public void reloadPreferences(Consumer<Preferences> callback){
new GetPreferences()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Preferences result){
preferences=result;
callback.accept(result);
AccountSessionManager.getInstance().writeAccountsFile();
}
@Override
public void onError(ErrorResponse error){
Log.w(TAG, "Failed to load preferences for account "+getID()+": "+error);
}
})
.exec(getID());
}
}

View File

@@ -73,6 +73,7 @@ import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewcontrollers.ComposeAutocompleteViewController;
import org.joinmastodon.android.ui.viewcontrollers.ComposeLanguageAlertViewController;
import org.joinmastodon.android.ui.viewcontrollers.ComposeMediaViewController;
import org.joinmastodon.android.ui.viewcontrollers.ComposePollViewController;
import org.joinmastodon.android.ui.views.ComposeEditText;
@@ -122,7 +123,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private String accountID;
private int charCount, charLimit, trimmedCharCount;
private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn;
private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, languageBtn;
private TextView replyText;
private Button visibilityBtn;
private LinearLayout bottomBar;
@@ -142,6 +143,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC;
private ComposeAutocompleteSpan currentAutocompleteSpan;
private FrameLayout mainEditTextWrap;
private ComposeLanguageAlertViewController.SelectedOption postLang;
private ComposeAutocompleteViewController autocompleteViewController;
private ComposePollViewController pollViewController=new ComposePollViewController(this);
@@ -190,9 +192,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
else
charLimit=500;
if(editingStatus==null)
loadDefaultStatusVisibility(savedInstanceState);
setTitle(editingStatus==null ? R.string.new_post : R.string.edit_post);
if(savedInstanceState!=null)
postLang=Parcels.unwrap(savedInstanceState.getParcelable("postLang"));
}
@Override
@@ -251,12 +253,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
emojiBtn=view.findViewById(R.id.btn_emoji);
spoilerBtn=view.findViewById(R.id.btn_spoiler);
visibilityBtn=view.findViewById(R.id.btn_visibility);
languageBtn=view.findViewById(R.id.btn_language);
replyText=view.findViewById(R.id.reply_text);
mediaBtn.setOnClickListener(v->openFilePicker());
pollBtn.setOnClickListener(v->togglePoll());
emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText));
spoilerBtn.setOnClickListener(v->toggleSpoiler());
languageBtn.setOnClickListener(v->showLanguageAlert());
visibilityBtn.setOnClickListener(this::onVisibilityClick);
Drawable arrow=getResources().getDrawable(R.drawable.ic_baseline_arrow_drop_down_18, getActivity().getTheme()).mutate();
arrow.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurface));
@@ -343,6 +347,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
mediaViewController.onSaveInstanceState(outState);
outState.putBoolean("hasSpoiler", hasSpoiler);
outState.putSerializable("visibility", statusVisibility);
outState.putParcelable("postLang", Parcels.wrap(postLang));
if(currentAutocompleteSpan!=null){
Editable e=mainEditText.getText();
outState.putInt("autocompleteStart", e.getSpanStart(currentAutocompleteSpan));
@@ -358,6 +363,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
if(editingStatus==null)
loadDefaultStatusVisibility(savedInstanceState);
contentView.setSizeListener(emojiKeyboard::onContentViewSizeChanged);
InputMethodManager imm=getActivity().getSystemService(InputMethodManager.class);
mainEditText.requestFocus();
@@ -650,6 +657,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
if(hasSpoiler && spoilerEdit.length()>0){
req.spoilerText=spoilerEdit.getText().toString();
}
if(postLang!=null){
req.language=postLang.locale.toLanguageTag();
}
if(uuid==null)
uuid=UUID.randomUUID().toString();
@@ -867,45 +877,43 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
menu.show();
}
private void loadDefaultStatusVisibility(Bundle savedInstanceState) {
private void loadDefaultStatusVisibility(Bundle savedInstanceState){
if(getArguments().containsKey("replyTo")){
replyTo=Parcels.unwrap(getArguments().getParcelable("replyTo"));
statusVisibility = replyTo.visibility;
statusVisibility=replyTo.visibility;
}
// A saved privacy setting from a previous compose session wins over the reply visibility
if(savedInstanceState !=null){
statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
if(savedInstanceState!=null){
statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility");
}
new GetPreferences()
.setCallback(new Callback<>(){
@Override
public void onSuccess(Preferences result){
// Only override the reply visibility if our preference is more private
if (result.postingDefaultVisibility.isLessVisibleThan(statusVisibility)) {
// Map unlisted from the API onto public, because we don't have unlisted in the UI
statusVisibility = switch (result.postingDefaultVisibility) {
case PUBLIC, UNLISTED -> StatusPrivacy.PUBLIC;
case PRIVATE -> StatusPrivacy.PRIVATE;
case DIRECT -> StatusPrivacy.DIRECT;
};
}
Preferences prevPrefs=AccountSessionManager.getInstance().getAccount(accountID).preferences;
if(prevPrefs!=null){
applyPreferencesForPostVisibility(prevPrefs, savedInstanceState);
}
AccountSessionManager.getInstance().getAccount(accountID).reloadPreferences(prefs->{
applyPreferencesForPostVisibility(prefs, savedInstanceState);
});
}
// A saved privacy setting from a previous compose session wins over all
if(savedInstanceState !=null){
statusVisibility = (StatusPrivacy) savedInstanceState.getSerializable("visibility");
}
private void applyPreferencesForPostVisibility(Preferences prefs, Bundle savedInstanceState){
// Only override the reply visibility if our preference is more private
if(prefs.postingDefaultVisibility.isLessVisibleThan(statusVisibility)){
// Map unlisted from the API onto public, because we don't have unlisted in the UI
statusVisibility=switch(prefs.postingDefaultVisibility){
case PUBLIC, UNLISTED -> StatusPrivacy.PUBLIC;
case PRIVATE -> StatusPrivacy.PRIVATE;
case DIRECT -> StatusPrivacy.DIRECT;
};
}
updateVisibilityIcon ();
}
// A saved privacy setting from a previous compose session wins over all
if(savedInstanceState!=null){
statusVisibility=(StatusPrivacy) savedInstanceState.getSerializable("visibility");
}
@Override
public void onError(ErrorResponse error){
Log.w(TAG, "Unable to get user preferences to set default post privacy");
}
})
.exec(accountID);
updateVisibilityIcon();
}
private void updateVisibilityIcon(){
@@ -1037,4 +1045,19 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
public void addFakeMediaAttachment(Uri uri, String description){
mediaViewController.addFakeMediaAttachment(uri, description);
}
private void showLanguageAlert(){
Preferences prefs=AccountSessionManager.getInstance().getAccount(accountID).preferences;
ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), prefs!=null ? prefs.postingDefaultLanguage : null, postLang, mainEditText.getText().toString());
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.language)
.setView(vc.getView())
.setPositiveButton(R.string.ok, (dialog, which)->setPostLanguage(vc.getSelectedOption()))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void setPostLanguage(ComposeLanguageAlertViewController.SelectedOption language){
postLang=language;
}
}

View File

@@ -0,0 +1,333 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Build;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.textclassifier.TextClassificationManager;
import android.view.textclassifier.TextLanguage;
import android.widget.Checkable;
import android.widget.CheckedTextView;
import android.widget.RadioButton;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.CheckableLinearLayout;
import org.parceler.Parcel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class ComposeLanguageAlertViewController{
private Context context;
private UsableRecyclerView list;
private List<LocaleInfo> allLocales;
private List<SpecialLocaleInfo> specialLocales=new ArrayList<>();
private int selectedIndex=0;
private Locale selectedLocale;
public ComposeLanguageAlertViewController(Context context, String preferred, SelectedOption previouslySelected, String postText){
this.context=context;
allLocales=Arrays.stream(Locale.getAvailableLocales())
.map(Locale::getLanguage)
.distinct()
.map(code->{
Locale l=Locale.forLanguageTag(code);
String name=l.getDisplayLanguage(Locale.getDefault());
return new LocaleInfo(l, capitalizeLanguageName(name));
})
.sorted(Comparator.comparing(a->a.displayName))
.collect(Collectors.toList());
if(!TextUtils.isEmpty(preferred)){
Locale l=Locale.forLanguageTag(preferred);
SpecialLocaleInfo pref=new SpecialLocaleInfo();
pref.locale=l;
pref.displayName=capitalizeLanguageName(l.getDisplayLanguage(Locale.getDefault()));
pref.title=context.getString(R.string.language_default);
specialLocales.add(pref);
}
Locale def=Locale.forLanguageTag(Locale.getDefault().getLanguage());
if(!def.getLanguage().equals(preferred)){
SpecialLocaleInfo d=new SpecialLocaleInfo();
d.locale=def;
d.displayName=capitalizeLanguageName(def.getDisplayName());
d.title=context.getString(R.string.language_system);
specialLocales.add(d);
}
if(Build.VERSION.SDK_INT>=29 && !TextUtils.isEmpty(postText)){
SpecialLocaleInfo detected=new SpecialLocaleInfo();
detected.displayName=context.getString(R.string.language_detecting);
detected.enabled=false;
specialLocales.add(detected);
detectLanguage(detected, postText);
}
if(previouslySelected!=null){
if((previouslySelected.index<specialLocales.size() && Objects.equals(previouslySelected.locale, specialLocales.get(previouslySelected.index).locale)) ||
(previouslySelected.index<specialLocales.size()+allLocales.size() && Objects.equals(previouslySelected.locale, allLocales.get(previouslySelected.index-specialLocales.size()).locale))){
selectedIndex=previouslySelected.index;
selectedLocale=previouslySelected.locale;
}
}else{
selectedLocale=specialLocales.get(0).locale;
}
list=new UsableRecyclerView(context);
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SpecialLanguagesAdapter());
adapter.addAdapter(new AllLocalesAdapter());
list.setAdapter(adapter);
list.setLayoutManager(new LinearLayoutManager(context));
list.addItemDecoration(new DividerItemDecoration(context, R.attr.colorM3OutlineVariant, 1, 16, 16, vh->vh.getAbsoluteAdapterPosition()==specialLocales.size()-1));
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint();
{
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3OutlineVariant));
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
}
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.canScrollVertically(1)){
float y=parent.getHeight()-paint.getStrokeWidth()/2f;
c.drawLine(0, y, parent.getWidth(), y, paint);
}
if(parent.canScrollVertically(-1)){
float y=paint.getStrokeWidth()/2f;
c.drawLine(0, y, parent.getWidth(), y, paint);
}
}
});
if(previouslySelected!=null && selectedIndex>0){
list.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
list.getViewTreeObserver().removeOnPreDrawListener(this);
if(list.findViewHolderForAdapterPosition(selectedIndex)==null)
list.scrollToPosition(selectedIndex);
return true;
}
});
}
}
@RequiresApi(api = Build.VERSION_CODES.Q)
private void detectLanguage(SpecialLocaleInfo info, String text){
MastodonAPIController.runInBackground(()->{
TextLanguage lang=context.getSystemService(TextClassificationManager.class).getTextClassifier().detectLanguage(new TextLanguage.Request.Builder(text).build());
list.post(()->{
SpecialLanguageViewHolder holder=(SpecialLanguageViewHolder) list.findViewHolderForAdapterPosition(specialLocales.indexOf(info));
if(lang.getLocaleHypothesisCount()==0 || lang.getConfidenceScore(lang.getLocale(0))<0.75f){
info.displayName=context.getString(R.string.language_cant_detect);
}else{
Locale locale=lang.getLocale(0).toLocale();
info.locale=locale;
info.displayName=capitalizeLanguageName(locale.getDisplayName(Locale.getDefault()));
info.title=context.getString(R.string.language_detected);
info.enabled=true;
if(holder!=null)
UiUtils.beginLayoutTransition(holder.view);
}
if(holder!=null)
holder.rebind();
});
});
}
public View getView(){
return list;
}
// Needed because in some languages (e.g. Slavic ones) these names returned by the system start with a lowercase letter
private String capitalizeLanguageName(String name){
return name.substring(0, 1).toUpperCase(Locale.getDefault())+name.substring(1);
}
public SelectedOption getSelectedOption(){
return new SelectedOption(selectedIndex, selectedLocale);
}
private void selectItem(int index){
if(index==selectedIndex)
return;
if(selectedIndex!=-1){
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(selectedIndex);
if(holder!=null && holder.itemView instanceof Checkable checkable)
checkable.setChecked(false);
}
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(index);
if(holder!=null && holder.itemView instanceof Checkable checkable)
checkable.setChecked(true);
selectedIndex=index;
}
private class AllLocalesAdapter extends RecyclerView.Adapter<SimpleLanguageViewHolder>{
@NonNull
@Override
public SimpleLanguageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new SimpleLanguageViewHolder();
}
@Override
public void onBindViewHolder(@NonNull SimpleLanguageViewHolder holder, int position){
holder.bind(allLocales.get(position));
}
@Override
public int getItemCount(){
return allLocales.size();
}
@Override
public int getItemViewType(int position){
return 1;
}
}
private class SimpleLanguageViewHolder extends BindableViewHolder<LocaleInfo> implements UsableRecyclerView.Clickable{
private final CheckedTextView text;
public SimpleLanguageViewHolder(){
super(context, R.layout.item_alert_single_choice_1line, list);
text=(CheckedTextView) itemView;
text.setCompoundDrawablesRelativeWithIntrinsicBounds(new RadioButton(context).getButtonDrawable(), null, null, null);
}
@Override
public void onBind(LocaleInfo item){
text.setText(item.displayName);
text.setChecked(selectedIndex==getAbsoluteAdapterPosition());
}
@Override
public void onClick(){
selectItem(getAbsoluteAdapterPosition());
selectedLocale=item.locale;
}
}
private class SpecialLanguagesAdapter extends RecyclerView.Adapter<SpecialLanguageViewHolder>{
@NonNull
@Override
public SpecialLanguageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new SpecialLanguageViewHolder();
}
@Override
public void onBindViewHolder(@NonNull SpecialLanguageViewHolder holder, int position){
holder.bind(specialLocales.get(position));
}
@Override
public int getItemCount(){
return specialLocales.size();
}
@Override
public int getItemViewType(int position){
return 2;
}
}
private class SpecialLanguageViewHolder extends BindableViewHolder<SpecialLocaleInfo> implements UsableRecyclerView.DisableableClickable{
private final TextView text, title;
private final CheckableLinearLayout view;
public SpecialLanguageViewHolder(){
super(context, R.layout.item_alert_single_choice_2lines, list);
text=findViewById(R.id.text);
title=findViewById(R.id.title);
view=((CheckableLinearLayout) itemView);
findViewById(R.id.radiobutton).setBackground(new RadioButton(context).getButtonDrawable());
}
@Override
public void onBind(SpecialLocaleInfo item){
text.setText(item.displayName);
if(!TextUtils.isEmpty(item.title)){
title.setVisibility(View.VISIBLE);
title.setText(item.title);
}else{
title.setVisibility(View.GONE);
}
text.setEnabled(item.enabled);
view.setEnabled(item.enabled);
view.setChecked(selectedIndex==getAbsoluteAdapterPosition());
}
@Override
public void onClick(){
selectItem(getAbsoluteAdapterPosition());
selectedLocale=item.locale;
}
@Override
public boolean isEnabled(){
return item.enabled;
}
}
private static class LocaleInfo{
public final Locale locale;
public final String displayName;
private LocaleInfo(Locale locale, String displayName){
this.locale=locale;
this.displayName=displayName;
}
}
private static class SpecialLocaleInfo{
public Locale locale;
public String displayName;
public String title;
public boolean enabled=true;
}
@Parcel
public static class SelectedOption{
public int index;
public Locale locale;
public SelectedOption(){}
public SelectedOption(int index, Locale locale){
this.index=index;
this.locale=locale;
}
}
}