diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 43a5dc008..294d02395 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -10,7 +10,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 31 - versionCode 24 + versionCode 25 versionName "0.1" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index f66aad420..a44345042 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -13,7 +13,10 @@ import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.Status; @@ -22,6 +25,7 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Collectors; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; @@ -45,22 +49,31 @@ public class CacheController{ this.accountID=accountID; } - public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback> callback){ + public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ + List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); try(Cursor cursor=db.query("home_timeline", new String[]{"json"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); + String newMaxID; + outer: do{ Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); status.postprocess(); + newMaxID=status.id; + for(Filter filter:filters){ + if(filter.matches(status.getContentStatus().content)) + continue outer; + } result.add(status); }while(cursor.moveToNext()); - uiHandler.post(()->callback.onSuccess(result)); + String _newMaxID=newMaxID; + uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID))); return; } }catch(IOException x){ @@ -71,7 +84,14 @@ public class CacheController{ .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(result); + callback.onSuccess(new PaginatedResponse<>(result.stream().filter(post->{ + for(Filter filter:filters){ + if(filter.matches(post.getContentStatus().content)){ + return false; + } + } + return true; + }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id)); putHomeTimeline(result, maxID==null); } @@ -103,22 +123,33 @@ public class CacheController{ }); } - public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback> callback){ + public void getNotifications(String maxID, int count, boolean onlyMentions, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ + List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); try(Cursor cursor=db.query(onlyMentions ? "notifications_mentions" : "notifications_all", new String[]{"json"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); + String newMaxID; + outer: do{ Notification ntf=MastodonAPIController.gson.fromJson(cursor.getString(0), Notification.class); ntf.postprocess(); + newMaxID=ntf.id; + if(ntf.status!=null){ + for(Filter filter:filters){ + if(filter.matches(ntf.status.getContentStatus().content)) + continue outer; + } + } result.add(ntf); }while(cursor.moveToNext()); - uiHandler.post(()->callback.onSuccess(result)); + String _newMaxID=newMaxID; + uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID))); return; } }catch(IOException x){ @@ -129,7 +160,16 @@ public class CacheController{ .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(result); + callback.onSuccess(new PaginatedResponse<>(result.stream().filter(ntf->{ + if(ntf.status!=null){ + for(Filter filter:filters){ + if(filter.matches(ntf.status.getContentStatus().content)){ + return false; + } + } + } + return true; + }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id)); putNotifications(result, onlyMentions, maxID==null); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java new file mode 100644 index 000000000..781035959 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java @@ -0,0 +1,14 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Filter; + +import java.util.List; + +public class GetWordFilters extends MastodonAPIRequest>{ + public GetWordFilters(){ + super(HttpMethod.GET, "/filters", new TypeToken<>(){}); + } +} 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 b8ac38bba..48fbb1bc3 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 @@ -6,9 +6,13 @@ import org.joinmastodon.android.api.PushSubscriptionManager; import org.joinmastodon.android.api.StatusInteractionController; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.Token; +import java.util.ArrayList; +import java.util.List; + public class AccountSession{ public Token token; public Account self; @@ -21,6 +25,8 @@ public class AccountSession{ public String pushAuthKey; public PushSubscription pushSubscription; public boolean needUpdatePushSettings; + public long filtersLastUpdated; + public List wordFilters=new ArrayList<>(); private transient MastodonAPIController apiController; private transient StatusInteractionController statusInteractionController; private transient CacheController cacheController; diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 5a28b7c4f..8e5103a3c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -15,6 +15,7 @@ import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.PushSubscriptionManager; +import org.joinmastodon.android.api.requests.accounts.GetWordFilters; import org.joinmastodon.android.api.requests.instance.GetCustomEmojis; import org.joinmastodon.android.api.requests.accounts.GetOwnAccount; import org.joinmastodon.android.api.requests.instance.GetInstance; @@ -24,6 +25,7 @@ import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.EmojiCategory; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Token; @@ -228,6 +230,9 @@ public class AccountSessionManager{ if(now-session.infoLastUpdated>24L*3600_000L){ updateSessionLocalInfo(session); } + if(now-session.filtersLastUpdated>3600_000L){ + updateSessionWordFilters(session); + } } if(loadedInstances){ maybeUpdateCustomEmojis(domains); @@ -262,6 +267,24 @@ public class AccountSessionManager{ .exec(session.getID()); } + private void updateSessionWordFilters(AccountSession session){ + new GetWordFilters() + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + session.wordFilters=result; + session.filtersLastUpdated=System.currentTimeMillis(); + writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error){ + + } + }) + .exec(session.getID()); + } + public void updateInstanceInfo(String domain){ new GetInstance() .setCallback(new Callback<>(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index ddc8a85f3..48a7c5ec5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -18,6 +18,7 @@ import com.squareup.otto.Subscribe; import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.utils.UiUtils; @@ -30,6 +31,8 @@ import me.grishka.appkit.api.SimpleCallback; public class HomeTimelineFragment extends StatusListFragment{ private ImageButton fab; + private String maxID; + public HomeTimelineFragment(){ setListLayoutId(R.layout.recycler_fragment_with_fab); } @@ -45,12 +48,13 @@ public class HomeTimelineFragment extends StatusListFragment{ protected void doLoadData(int offset, int count){ AccountSessionManager.getInstance() .getAccount(accountID).getCacheController() - .getHomeTimeline(offset>0 ? getMaxID() : null, count, refreshing, new SimpleCallback<>(this){ + .getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){ @Override - public void onSuccess(List result){ + public void onSuccess(PaginatedResponse> result){ if(getActivity()==null) return; - onDataLoaded(result, !result.isEmpty()); + onDataLoaded(result.items, !result.items.isEmpty()); + maxID=result.maxID; } }); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index e141c348a..d744d0632 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -15,6 +15,7 @@ import org.joinmastodon.android.R; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.PollUpdatedEvent; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.PaginatedResponse; import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.PhotoLayoutHelper; @@ -40,6 +41,7 @@ import me.grishka.appkit.utils.V; public class NotificationsListFragment extends BaseStatusListFragment{ private boolean onlyMentions; + private String maxID; @Override public void onCreate(Bundle savedInstanceState){ @@ -100,19 +102,20 @@ public class NotificationsListFragment extends BaseStatusListFragment0 ? getMaxID() : null, count, onlyMentions, refreshing, new SimpleCallback<>(this){ + .getNotifications(offset>0 ? maxID : null, count, onlyMentions, refreshing, new SimpleCallback<>(this){ @Override - public void onSuccess(List result){ + public void onSuccess(PaginatedResponse> result){ if(getActivity()==null) return; if(refreshing) relationships.clear(); - onDataLoaded(result.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.isEmpty()); - Set needRelationships=result.stream() + onDataLoaded(result.items.stream().filter(n->n.type!=null).collect(Collectors.toList()), !result.items.isEmpty()); + Set needRelationships=result.items.stream() .filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id)) .map(ntf->ntf.account.id) .collect(Collectors.toSet()); loadRelationships(needRelationships); + maxID=result.maxID; } }); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index 53a16d983..2f681b1b8 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -5,8 +5,10 @@ import android.view.View; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetStatusContext; +import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; @@ -17,6 +19,7 @@ import org.parceler.Parcels; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; @@ -59,6 +62,8 @@ public class ThreadFragment extends StatusListFragment{ data.add(mainStatus); onAppendItems(Collections.singletonList(mainStatus)); } + result.descendants=filterStatuses(result.descendants); + result.ancestors=filterStatuses(result.ancestors); footerProgress.setVisibility(View.GONE); data.addAll(result.descendants); int prevCount=displayItems.size(); @@ -78,6 +83,19 @@ public class ThreadFragment extends StatusListFragment{ .exec(accountID); } + private List filterStatuses(List statuses){ + List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.THREAD)).collect(Collectors.toList()); + if(filters.isEmpty()) + return statuses; + return statuses.stream().filter(status->{ + for(Filter filter:filters){ + if(filter.matches(status.getContentStatus().content)) + return false; + } + return true; + }).collect(Collectors.toList()); + } + @Override protected void onShown(){ super.onShown(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java new file mode 100644 index 000000000..7084798ee --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java @@ -0,0 +1,60 @@ +package org.joinmastodon.android.model; + +import android.text.TextUtils; + +import com.google.gson.annotations.SerializedName; + +import org.joinmastodon.android.api.RequiredField; + +import java.time.Instant; +import java.util.EnumSet; +import java.util.regex.Pattern; + +public class Filter extends BaseModel{ + @RequiredField + public String id; + @RequiredField + public String phrase; + @RequiredField + public EnumSet context; + public Instant expiresAt; + public boolean irreversible; + public boolean wholeWord; + + private transient Pattern pattern; + + public boolean matches(CharSequence text){ + if(TextUtils.isEmpty(text)) + return false; + if(pattern==null){ + if(wholeWord) + pattern=Pattern.compile("\\b"+Pattern.quote(phrase)+"\\b", Pattern.CASE_INSENSITIVE); + else + pattern=Pattern.compile(Pattern.quote(phrase), Pattern.CASE_INSENSITIVE); + } + return pattern.matcher(text).find(); + } + + @Override + public String toString(){ + return "Filter{"+ + "id='"+id+'\''+ + ", phrase='"+phrase+'\''+ + ", context="+context+ + ", expiresAt="+expiresAt+ + ", irreversible="+irreversible+ + ", wholeWord="+wholeWord+ + '}'; + } + + public enum FilterContext{ + @SerializedName("home") + HOME, + @SerializedName("notifications") + NOTIFICATIONS, + @SerializedName("public") + PUBLIC, + @SerializedName("thread") + THREAD + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PaginatedResponse.java b/mastodon/src/main/java/org/joinmastodon/android/model/PaginatedResponse.java new file mode 100644 index 000000000..53345c9fa --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PaginatedResponse.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.model; + +public class PaginatedResponse{ + public T items; + public String maxID; + + public PaginatedResponse(T items, String maxID){ + this.items=items; + this.maxID=maxID; + } +}