From 15883f2138e03d05e958101259ade5e0f8787445 Mon Sep 17 00:00:00 2001 From: Grishka Date: Fri, 12 May 2023 22:21:21 +0300 Subject: [PATCH] Compose: language selection --- .../android/api/session/AccountSession.java | 29 ++ .../android/fragments/ComposeFragment.java | 87 +++-- .../ComposeLanguageAlertViewController.java | 333 ++++++++++++++++++ .../res/color/text_on_surface_disableable.xml | 5 + .../main/res/drawable/ic_language_24px.xml | 9 + .../src/main/res/layout/fragment_compose.xml | 13 + .../layout/item_alert_single_choice_1line.xml | 16 + .../item_alert_single_choice_2lines.xml | 46 +++ mastodon/src/main/res/values/strings.xml | 6 + 9 files changed, 512 insertions(+), 32 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java create mode 100644 mastodon/src/main/res/color/text_on_surface_disableable.xml create mode 100644 mastodon/src/main/res/drawable/ic_language_24px.xml create mode 100644 mastodon/src/main/res/layout/item_alert_single_choice_1line.xml create mode 100644 mastodon/src/main/res/layout/item_alert_single_choice_2lines.xml diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 321b7072..cc2868ae 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -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 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 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()); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 2d6ca8d1..3143f514 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -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; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java new file mode 100644 index 00000000..badcdd50 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java @@ -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 allLocales; + private List 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.indexvh.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{ + + @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 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{ + + @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 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; + } + } +} diff --git a/mastodon/src/main/res/color/text_on_surface_disableable.xml b/mastodon/src/main/res/color/text_on_surface_disableable.xml new file mode 100644 index 00000000..4872f0e5 --- /dev/null +++ b/mastodon/src/main/res/color/text_on_surface_disableable.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_language_24px.xml b/mastodon/src/main/res/drawable/ic_language_24px.xml new file mode 100644 index 00000000..3b487128 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_language_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/layout/fragment_compose.xml b/mastodon/src/main/res/layout/fragment_compose.xml index 8b61ab86..9937c1f5 100644 --- a/mastodon/src/main/res/layout/fragment_compose.xml +++ b/mastodon/src/main/res/layout/fragment_compose.xml @@ -388,6 +388,19 @@ android:tooltipText="@string/content_warning" android:src="@drawable/ic_compose_cw"/> + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_alert_single_choice_2lines.xml b/mastodon/src/main/res/layout/item_alert_single_choice_2lines.xml new file mode 100644 index 00000000..be21ff8b --- /dev/null +++ b/mastodon/src/main/res/layout/item_alert_single_choice_2lines.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 9b16d286..db61d62a 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -484,4 +484,10 @@ Browse emoji Find who you\'re looking for Could not find anything for these search terms + Language + Default + System + Detecting language + Unable to detect language + Detected \ No newline at end of file