Settings M3 redesign wip

This commit is contained in:
Grishka
2023-06-04 02:04:55 +03:00
parent 7c6ec2e3d7
commit 31c8665653
139 changed files with 4520 additions and 1145 deletions

View File

@@ -95,7 +95,7 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
if(state!=UpdateState.NO_UPDATE && state!=UpdateState.UPDATE_AVAILABLE)
return;
long timeSinceLastCheck=System.currentTimeMillis()-getPrefs().getLong("lastCheck", 0);
if(timeSinceLastCheck>CHECK_PERIOD){
if(timeSinceLastCheck>CHECK_PERIOD || forceUpdate){
setState(UpdateState.CHECKING);
MastodonAPIController.runInBackground(this::actuallyCheckForUpdates);
}
@@ -109,23 +109,26 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
try(Response resp=call.execute()){
JsonObject obj=JsonParser.parseReader(resp.body().charStream()).getAsJsonObject();
String tag=obj.get("tag_name").getAsString();
Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)\\.(\\d+)");
Pattern pattern=Pattern.compile("v?(\\d+)\\.(\\d+)(?:\\.(\\d+))?");
Matcher matcher=pattern.matcher(tag);
if(!matcher.find()){
Log.w(TAG, "actuallyCheckForUpdates: release tag has wrong format: "+tag);
return;
}
int newMajor=Integer.parseInt(matcher.group(1)), newMinor=Integer.parseInt(matcher.group(2)), newRevision=Integer.parseInt(matcher.group(3));
matcher=pattern.matcher(BuildConfig.VERSION_NAME);
if(!matcher.find()){
int newMajor=Integer.parseInt(matcher.group(1)), newMinor=Integer.parseInt(matcher.group(2)), newRevision=matcher.group(3)!=null ? Integer.parseInt(matcher.group(3)) : 0;
Matcher curMatcher=pattern.matcher(BuildConfig.VERSION_NAME);
if(!curMatcher.find()){
Log.w(TAG, "actuallyCheckForUpdates: current version has wrong format: "+BuildConfig.VERSION_NAME);
return;
}
int curMajor=Integer.parseInt(matcher.group(1)), curMinor=Integer.parseInt(matcher.group(2)), curRevision=Integer.parseInt(matcher.group(3));
int curMajor=Integer.parseInt(curMatcher.group(1)), curMinor=Integer.parseInt(curMatcher.group(2)), curRevision=matcher.group(3)!=null ? Integer.parseInt(curMatcher.group(3)) : 0;
long newVersion=((long)newMajor << 32) | ((long)newMinor << 16) | newRevision;
long curVersion=((long)curMajor << 32) | ((long)curMinor << 16) | curRevision;
if(newVersion>curVersion || BuildConfig.DEBUG){
String version=newMajor+"."+newMinor+"."+newRevision;
if(newVersion>curVersion || forceUpdate){
forceUpdate=false;
String version=newMajor+"."+newMinor;
if(matcher.group(3)!=null)
version+="."+newRevision;
Log.d(TAG, "actuallyCheckForUpdates: new version: "+version);
for(JsonElement el:obj.getAsJsonArray("assets")){
JsonObject asset=el.getAsJsonObject();
@@ -295,6 +298,15 @@ public class GithubSelfUpdaterImpl extends GithubSelfUpdater{
}
}
@Override
public void reset(){
getPrefs().edit().clear().apply();
File apk=getUpdateApkFile();
if(apk.exists())
apk.delete();
state=UpdateState.NO_UPDATE;
}
/*public static class InstallerStatusReceiver extends BroadcastReceiver{
@Override

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<style>
*{
box-sizing: border-box;
overflow-wrap: break-word;
}
body{
background: {{colorSurface}};
padding: 16px 16px 0 16px;
margin: 0;
color: {{colorOnSurface}};
font-family: Roboto, sans-serif;
font-size: 14px;
line-height: 20px;
-webkit-tap-highlight-color: {{colorPrimaryTransparent}};
}
a{
text-decoration: none;
color: {{colorPrimary}};
}
p, h1, h2, h3, h4, h5, h6, ul, ol{
margin-bottom: 8px;
margin-top: 0;
}
h1, h2{
font-size: 16px;
line-height: 24px;
font-weight: 500;
}
h3, h4, h5, h6{
font-size: 14px;
line-height: 20px;
font-weight: 500;
}
b, strong{
font-weight: 500;
}
ul, ol{
padding-inline-start: 16px;
}
ul>li, ol>li{
padding-inline-start: 4px;
}
</style>
</head>
<body>
{{content}}
</body>
</html>

View File

@@ -6,7 +6,7 @@ import android.content.SharedPreferences;
public class GlobalUserPreferences{
public static boolean playGifs;
public static boolean useCustomTabs;
public static boolean trueBlackTheme;
public static boolean altTextReminders, confirmUnfollow, confirmBoost, confirmDeletePost;
public static ThemePreference theme;
private static SharedPreferences getPrefs(){
@@ -17,7 +17,10 @@ public class GlobalUserPreferences{
SharedPreferences prefs=getPrefs();
playGifs=prefs.getBoolean("playGifs", true);
useCustomTabs=prefs.getBoolean("useCustomTabs", true);
trueBlackTheme=prefs.getBoolean("trueBlackTheme", false);
altTextReminders=prefs.getBoolean("altTextReminders", false);
confirmUnfollow=prefs.getBoolean("confirmUnfollow", false);
confirmBoost=prefs.getBoolean("confirmBoost", false);
confirmDeletePost=prefs.getBoolean("confirmDeletePost", true);
theme=ThemePreference.values()[prefs.getInt("theme", 0)];
}
@@ -25,8 +28,11 @@ public class GlobalUserPreferences{
getPrefs().edit()
.putBoolean("playGifs", playGifs)
.putBoolean("useCustomTabs", useCustomTabs)
.putBoolean("trueBlackTheme", trueBlackTheme)
.putInt("theme", theme.ordinal())
.putBoolean("altTextReminders", altTextReminders)
.putBoolean("confirmUnfollow", confirmUnfollow)
.putBoolean("confirmBoost", confirmBoost)
.putBoolean("confirmDeletePost", confirmDeletePost)
.apply();
}

View File

@@ -3,11 +3,10 @@ package org.joinmastodon.android;
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.Context;
import android.webkit.WebView;
import org.joinmastodon.android.api.PushSubscriptionManager;
import java.lang.reflect.InvocationTargetException;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.NetworkUtils;
import me.grishka.appkit.utils.V;
@@ -30,5 +29,8 @@ public class MastodonApp extends Application{
PushSubscriptionManager.tryRegisterFCM();
GlobalUserPreferences.load();
if(BuildConfig.DEBUG){
WebView.setWebContentsDebuggingEnabled(true);
}
}
}

View File

@@ -69,6 +69,10 @@ public class PushNotificationReceiver extends BroadcastReceiver{
Log.w(TAG, "onReceive: account for id '"+pushAccountID+"' not found");
return;
}
if(account.getLocalPreferences().getNotificationsPauseEndTime()>System.currentTimeMillis()){
Log.i(TAG, "onReceive: dropping notification because user has paused notifications for this account");
return;
}
String accountID=account.getID();
PushNotification pn=AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().decryptNotification(k, p, s);
new GetNotificationByID(pn.notificationId+"")

View File

@@ -15,7 +15,8 @@ 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.CacheablePaginatedResponse;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.SearchResult;
@@ -59,7 +60,7 @@ public class CacheController{
cancelDelayedClose();
databaseThread.postRunnable(()->{
try{
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.HOME)).collect(Collectors.toList());
if(!forceReload){
SQLiteDatabase db=getOrOpenDatabase();
try(Cursor cursor=db.query("home_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
@@ -74,7 +75,7 @@ public class CacheController{
int flags=cursor.getInt(1);
status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0);
newMaxID=status.id;
for(Filter filter:filters){
for(LegacyFilter filter:filters){
if(filter.matches(status))
continue outer;
}
@@ -139,7 +140,7 @@ public class CacheController{
}
return;
}
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList());
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(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`<?", maxID==null ? null : new String[]{maxID}, null, null, "`time` DESC", count+"")){
@@ -153,7 +154,7 @@ public class CacheController{
ntf.postprocess();
newMaxID=ntf.id;
if(ntf.status!=null){
for(Filter filter:filters){
for(LegacyFilter filter:filters){
if(filter.matches(ntf.status))
continue outer;
}
@@ -176,7 +177,7 @@ public class CacheController{
public void onSuccess(List<Notification> result){
PaginatedResponse<List<Notification>> res=new PaginatedResponse<>(result.stream().filter(ntf->{
if(ntf.status!=null){
for(Filter filter:filters){
for(LegacyFilter filter:filters){
if(filter.matches(ntf.status)){
return false;
}

View File

@@ -122,13 +122,17 @@ public class MastodonAPIController{
Log.d(TAG, "["+(session==null ? "no-auth" : session.getID())+"] response body: "+respJson);
if(req.respTypeToken!=null)
respObj=gson.fromJson(respJson, req.respTypeToken.getType());
else
else if(req.respClass!=null)
respObj=gson.fromJson(respJson, req.respClass);
else
respObj=null;
}else{
if(req.respTypeToken!=null)
respObj=gson.fromJson(reader, req.respTypeToken.getType());
else
else if(req.respClass!=null)
respObj=gson.fromJson(reader, req.respClass);
else
respObj=null;
}
}catch(JsonIOException|JsonSyntaxException x){
if(BuildConfig.DEBUG)

View File

@@ -0,0 +1,9 @@
package org.joinmastodon.android.api;
import com.google.gson.reflect.TypeToken;
public abstract class ResultlessMastodonAPIRequest extends MastodonAPIRequest<Void>{
public ResultlessMastodonAPIRequest(HttpMethod method, String path){
super(method, path, (Class<Void>)null);
}
}

View File

@@ -0,0 +1,34 @@
package org.joinmastodon.android.api.requests.accounts;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.StatusPrivacy;
public class UpdateAccountCredentialsPreferences extends MastodonAPIRequest<Account>{
public UpdateAccountCredentialsPreferences(Preferences preferences, Boolean locked, Boolean discoverable){
super(HttpMethod.PATCH, "/accounts/update_credentials", Account.class);
setRequestBody(new Request(locked, discoverable, new RequestSource(preferences.postingDefaultVisibility, preferences.postingDefaultLanguage)));
}
private static class Request{
public Boolean locked, discoverable;
public RequestSource source;
public Request(Boolean locked, Boolean discoverable, RequestSource source){
this.locked=locked;
this.discoverable=discoverable;
this.source=source;
}
}
private static class RequestSource{
public StatusPrivacy privacy;
public String language;
public RequestSource(StatusPrivacy privacy, String language){
this.privacy=privacy;
this.language=language;
}
}
}

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.api.requests.filters;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterKeyword;
import java.util.EnumSet;
import java.util.List;
import java.util.stream.Collectors;
public class CreateFilter extends MastodonAPIRequest<Filter>{
public CreateFilter(String title, EnumSet<FilterContext> context, FilterAction action, int expiresIn, List<FilterKeyword> words){
super(HttpMethod.POST, "/filters", Filter.class);
setRequestBody(new FilterRequest(title, context, action, expiresIn==0 ? null : expiresIn, words.stream().map(w->new KeywordAttribute(null, null, w.keyword, w.wholeWord)).collect(Collectors.toList())));
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
}

View File

@@ -0,0 +1,14 @@
package org.joinmastodon.android.api.requests.filters;
import org.joinmastodon.android.api.ResultlessMastodonAPIRequest;
public class DeleteFilter extends ResultlessMastodonAPIRequest{
public DeleteFilter(String id){
super(HttpMethod.DELETE, "/filters/"+id);
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
}

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.api.requests.filters;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import java.util.EnumSet;
import java.util.List;
class FilterRequest{
public String title;
public EnumSet<FilterContext> context;
public FilterAction filterAction;
public Integer expiresIn;
public List<KeywordAttribute> keywordsAttributes;
public FilterRequest(String title, EnumSet<FilterContext> context, FilterAction filterAction, Integer expiresIn, List<KeywordAttribute> keywordsAttributes){
this.title=title;
this.context=context;
this.filterAction=filterAction;
this.expiresIn=expiresIn;
this.keywordsAttributes=keywordsAttributes;
}
}

View File

@@ -1,4 +1,4 @@
package org.joinmastodon.android.api.requests.accounts;
package org.joinmastodon.android.api.requests.filters;
import com.google.gson.reflect.TypeToken;
@@ -7,8 +7,13 @@ import org.joinmastodon.android.model.Filter;
import java.util.List;
public class GetWordFilters extends MastodonAPIRequest<List<Filter>>{
public GetWordFilters(){
public class GetFilters extends MastodonAPIRequest<List<Filter>>{
public GetFilters(){
super(HttpMethod.GET, "/filters", new TypeToken<>(){});
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
}

View File

@@ -0,0 +1,14 @@
package org.joinmastodon.android.api.requests.filters;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.LegacyFilter;
import java.util.List;
public class GetLegacyFilters extends MastodonAPIRequest<List<LegacyFilter>>{
public GetLegacyFilters(){
super(HttpMethod.GET, "/filters", new TypeToken<>(){});
}
}

View File

@@ -0,0 +1,18 @@
package org.joinmastodon.android.api.requests.filters;
import com.google.gson.annotations.SerializedName;
class KeywordAttribute{
public String id;
@SerializedName("_destroy")
public Boolean delete;
public String keyword;
public Boolean wholeWord;
public KeywordAttribute(String id, Boolean delete, String keyword, Boolean wholeWord){
this.id=id;
this.delete=delete;
this.keyword=keyword;
this.wholeWord=wholeWord;
}
}

View File

@@ -0,0 +1,30 @@
package org.joinmastodon.android.api.requests.filters;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterKeyword;
import java.util.EnumSet;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class UpdateFilter extends MastodonAPIRequest<Filter>{
public UpdateFilter(String id, String title, EnumSet<FilterContext> context, FilterAction action, int expiresIn, List<FilterKeyword> words, List<String> deletedWords){
super(HttpMethod.PUT, "/filters/"+id, Filter.class);
List<KeywordAttribute> attrs=Stream.of(
words.stream().map(w->new KeywordAttribute(w.id, null, w.keyword, w.wholeWord)),
deletedWords.stream().map(wid->new KeywordAttribute(wid, true, null, null))
).flatMap(Function.identity()).collect(Collectors.toList());
setRequestBody(new FilterRequest(title, context, action, expiresIn==0 ? null : expiresIn, attrs));
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
}

View File

@@ -0,0 +1,16 @@
package org.joinmastodon.android.api.requests.instance;
import org.joinmastodon.android.api.MastodonAPIRequest;
import java.time.Instant;
public class GetInstanceExtendedDescription extends MastodonAPIRequest<GetInstanceExtendedDescription.Response>{
public GetInstanceExtendedDescription(){
super(HttpMethod.GET, "/instance/extended_description", Response.class);
}
public static class Response{
public Instant updatedAt;
public String content;
}
}

View File

@@ -0,0 +1,37 @@
package org.joinmastodon.android.api.session;
import android.content.SharedPreferences;
public class AccountLocalPreferences{
private final SharedPreferences prefs;
public boolean showInteractionCounts;
public boolean customEmojiInNames;
public boolean showCWs;
public boolean hideSensitiveMedia;
public AccountLocalPreferences(SharedPreferences prefs){
this.prefs=prefs;
showInteractionCounts=prefs.getBoolean("interactionCounts", true);
customEmojiInNames=prefs.getBoolean("emojiInNames", true);
showCWs=prefs.getBoolean("showCWs", true);
hideSensitiveMedia=prefs.getBoolean("hideSensitive", true);
}
public long getNotificationsPauseEndTime(){
return prefs.getLong("notificationsPauseTime", 0L);
}
public void setNotificationsPauseEndTime(long time){
prefs.edit().putLong("notificationsPauseTime", time).apply();
}
public void save(){
prefs.edit()
.putBoolean("interactionCounts", showInteractionCounts)
.putBoolean("emojiInNames", customEmojiInNames)
.putBoolean("showCWs", showCWs)
.putBoolean("hideSensitive", hideSensitiveMedia)
.apply();
}
}

View File

@@ -1,5 +1,6 @@
package org.joinmastodon.android.api.session;
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
@@ -7,17 +8,20 @@ import android.util.Log;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
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.api.requests.accounts.UpdateAccountCredentialsPreferences;
import org.joinmastodon.android.api.requests.markers.GetMarkers;
import org.joinmastodon.android.api.requests.markers.SaveMarkers;
import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken;
import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Application;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.model.TimelineMarkers;
@@ -46,7 +50,7 @@ public class AccountSession{
public PushSubscription pushSubscription;
public boolean needUpdatePushSettings;
public long filtersLastUpdated;
public List<Filter> wordFilters=new ArrayList<>();
public List<LegacyFilter> wordFilters=new ArrayList<>();
public String pushAccountID;
public AccountActivationInfo activationInfo;
public Preferences preferences;
@@ -55,6 +59,8 @@ public class AccountSession{
private transient CacheController cacheController;
private transient PushSubscriptionManager pushSubscriptionManager;
private transient SharedPreferences prefs;
private transient boolean preferencesNeedSaving;
private transient AccountLocalPreferences localPreferences;
AccountSession(Token token, Account self, Application app, String domain, boolean activated, AccountActivationInfo activationInfo){
this.token=token;
@@ -106,7 +112,8 @@ public class AccountSession{
@Override
public void onSuccess(Preferences result){
preferences=result;
callback.accept(result);
if(callback!=null)
callback.accept(result);
AccountSessionManager.getInstance().writeAccountsFile();
}
@@ -118,7 +125,7 @@ public class AccountSession{
.exec(getID());
}
public SharedPreferences getLocalPreferences(){
public SharedPreferences getRawLocalPreferences(){
if(prefs==null)
prefs=MastodonApp.context.getSharedPreferences(getID(), Context.MODE_PRIVATE);
return prefs;
@@ -150,11 +157,60 @@ public class AccountSession{
}
public String getLastKnownNotificationsMarker(){
return getLocalPreferences().getString("notificationsMarker", null);
return getRawLocalPreferences().getString("notificationsMarker", null);
}
public void setNotificationsMarker(String id, boolean clearUnread){
getLocalPreferences().edit().putString("notificationsMarker", id).apply();
getRawLocalPreferences().edit().putString("notificationsMarker", id).apply();
E.post(new NotificationsMarkerUpdatedEvent(getID(), id, clearUnread));
}
public void logOut(Activity activity, Runnable onDone){
new RevokeOauthToken(app.clientId, app.clientSecret, token.accessToken)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
AccountSessionManager.getInstance().removeAccount(getID());
onDone.run();
}
@Override
public void onError(ErrorResponse error){
AccountSessionManager.getInstance().removeAccount(getID());
onDone.run();
}
})
.wrapProgress(activity, R.string.loading, false)
.exec(getID());
}
public void savePreferencesLater(){
preferencesNeedSaving=true;
}
public void savePreferencesIfPending(){
if(preferencesNeedSaving){
new UpdateAccountCredentialsPreferences(preferences, null, null)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Account result){
preferencesNeedSaving=false;
self=result;
AccountSessionManager.getInstance().writeAccountsFile();
}
@Override
public void onError(ErrorResponse error){
Log.e(TAG, "failed to save preferences: "+error);
}
})
.exec(getID());
}
}
public AccountLocalPreferences getLocalPreferences(){
if(localPreferences==null)
localPreferences=new AccountLocalPreferences(getRawLocalPreferences());
return localPreferences;
}
}

View File

@@ -13,8 +13,6 @@ import android.net.Uri;
import android.os.Build;
import android.util.Log;
import com.google.gson.JsonParseException;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MainActivity;
@@ -22,7 +20,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.filters.GetLegacyFilters;
import org.joinmastodon.android.api.requests.instance.GetCustomEmojis;
import org.joinmastodon.android.api.requests.accounts.GetOwnAccount;
import org.joinmastodon.android.api.requests.instance.GetInstance;
@@ -32,7 +30,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.LegacyFilter;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Token;
@@ -190,6 +188,7 @@ public class AccountSessionManager{
lastActiveAccountID=null;
else
lastActiveAccountID=getLoggedInAccounts().get(0).getID();
prefs.edit().putString("lastActiveAccount", lastActiveAccountID).apply();
}
writeAccountsFile();
String domain=session.domain.toLowerCase();
@@ -299,10 +298,10 @@ public class AccountSessionManager{
}
private void updateSessionWordFilters(AccountSession session){
new GetWordFilters()
new GetLegacyFilters()
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Filter> result){
public void onSuccess(List<LegacyFilter> result){
session.wordFilters=result;
session.filtersLastUpdated=System.currentTimeMillis();
writeAccountsFile();

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.events;
import org.joinmastodon.android.model.Filter;
public class SettingsFilterCreatedOrUpdatedEvent{
public final String accountID;
public final Filter filter;
public SettingsFilterCreatedOrUpdatedEvent(String accountID, Filter filter){
this.accountID=accountID;
this.filter=filter;
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.events;
public class SettingsFilterDeletedEvent{
public final String accountID;
public final String filterID;
public SettingsFilterDeletedEvent(String accountID, String filterID){
this.accountID=accountID;
this.filterID=filterID;
}
}

View File

@@ -0,0 +1,9 @@
package org.joinmastodon.android.events;
public class StatusDisplaySettingsChangedEvent{
public final String accountID;
public StatusDisplaySettingsChangedEvent(String accountID){
this.accountID=accountID;
}
}

View File

@@ -554,6 +554,14 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return attachmentViewsPool;
}
public void rebuildAllDisplayItems(){
displayItems.clear();
for(T item:data){
displayItems.addAll(buildDisplayItems(item));
}
adapter.notifyDataSetChanged();
}
protected void onModifyItemViewHolder(BindableViewHolder<StatusDisplayItem> holder){}
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{

View File

@@ -46,6 +46,7 @@ import android.widget.TextView;
import com.twitter.twittertext.TwitterTextEmojiRegex;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.accounts.GetPreferences;
@@ -546,7 +547,10 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(item.getItemId()==R.id.publish){
publish();
if(GlobalUserPreferences.altTextReminders)
checkAltTextsAndPublish();
else
publish();
}
return true;
}
@@ -641,6 +645,28 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
return true;
}
private void checkAltTextsAndPublish(){
int count=mediaViewController.getMissingAltTextAttachmentCount();
if(count==0){
publish();
}else{
String msg=getResources().getQuantityString(mediaViewController.areAllAttachmentsImages() ? R.plurals.alt_text_reminder_x_images : R.plurals.alt_text_reminder_x_attachments,
count, switch(count){
case 1 -> getString(R.string.count_one);
case 2 -> getString(R.string.count_two);
case 3 -> getString(R.string.count_three);
case 4 -> getString(R.string.count_four);
default -> String.valueOf(count);
});
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.alt_text_reminder_title)
.setMessage(msg)
.setPositiveButton(R.string.alt_text_reminder_post_anyway, (dlg, item)->publish())
.setNegativeButton(R.string.cancel, null)
.show();
}
}
private void publish(){
sendingOverlay=new View(getActivity());
WindowManager.LayoutParams overlayParams=new WindowManager.LayoutParams();
@@ -655,7 +681,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr
publishButton.setEnabled(false);
V.setVisibilityAnimated(sendProgress, View.VISIBLE);
mediaViewController.saveAltTextsBeforePublishing(this::actuallyPublish, this::handlePublishError);
}

View File

@@ -3,14 +3,11 @@ package org.joinmastodon.android.fragments;
import android.annotation.SuppressLint;
import android.app.Fragment;
import android.app.NotificationManager;
import android.graphics.Outline;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.widget.FrameLayout;
@@ -20,19 +17,19 @@ import android.widget.TextView;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.PushNotificationReceiver;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.markers.GetMarkers;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.NotificationsMarkerUpdatedEvent;
import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent;
import org.joinmastodon.android.fragments.discover.DiscoverFragment;
import org.joinmastodon.android.fragments.onboarding.OnboardingFollowSuggestionsFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.PaginatedResponse;
import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -265,7 +262,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
new AccountSwitcherSheet(getActivity(), this).show();
return true;
}
if(tab==R.id.tab_home){
if(tab==R.id.tab_home && BuildConfig.DEBUG){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), OnboardingFollowSuggestionsFragment.class, args);
@@ -328,7 +325,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
notificationsBadge.setVisibility(View.GONE);
}else{
notificationsBadge.setVisibility(View.VISIBLE);
if(notifications.get(notifications.size()-1).id.compareTo(marker)<=0){
if(notifications.get(notifications.size()-1).id.compareTo(marker)>0){
notificationsBadge.setText(String.format("%d+", notifications.size()));
}else{
int count=0;
@@ -349,4 +346,14 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
if(ev.clearUnread)
notificationsBadge.setVisibility(View.GONE);
}
@Subscribe
public void onStatusDisplaySettingsChanged(StatusDisplaySettingsChangedEvent ev){
if(!ev.accountID.equals(accountID))
return;
if(homeTimelineFragment.loaded)
homeTimelineFragment.rebuildAllDisplayItems();
if(notificationsFragment.loaded)
notificationsFragment.rebuildAllDisplayItems();
}
}

View File

@@ -30,8 +30,10 @@ import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.events.StatusCreatedEvent;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.CacheablePaginatedResponse;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineMarkers;
import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem;
@@ -123,7 +125,7 @@ public class HomeTimelineFragment extends StatusListFragment{
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), SettingsFragment.class, args);
Nav.go(getActivity(), SettingsMainFragment.class, args);
return true;
}
@@ -200,7 +202,7 @@ public class HomeTimelineFragment extends StatusListFragment{
result.get(result.size()-1).hasGapAfter=true;
toAdd=result;
}
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.HOME)).collect(Collectors.toList());
if(!filters.isEmpty()){
toAdd=toAdd.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList());
}
@@ -277,12 +279,12 @@ public class HomeTimelineFragment extends StatusListFragment{
List<StatusDisplayItem> targetList=displayItems.subList(gapPos, gapPos+1);
targetList.clear();
List<Status> insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1);
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList());
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.HOME)).collect(Collectors.toList());
outer:
for(Status s:result){
if(idsBelowGap.contains(s.id))
break;
for(Filter filter:filters){
for(LegacyFilter filter:filters){
if(filter.matches(s)){
continue outer;
}
@@ -444,6 +446,11 @@ public class HomeTimelineFragment extends StatusListFragment{
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_24_badged);
}
@Override
protected boolean wantsToolbarMenuIconsTinted(){
return false;
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateState(ev.state);

View File

@@ -315,10 +315,12 @@ public class NotificationsListFragment extends BaseStatusListFragment<Notificati
private void markAsRead(){
String id=data.get(0).id;
new SaveMarkers(null, id).exec(accountID);
AccountSessionManager.get(accountID).setNotificationsMarker(id, true);
realUnreadMarker=id;
updateMarkAllReadButton();
if(ObjectIdComparator.INSTANCE.compare(id, realUnreadMarker)>0){
new SaveMarkers(null, id).exec(accountID);
AccountSessionManager.get(accountID).setNotificationsMarker(id, true);
realUnreadMarker=id;
updateMarkAllReadButton();
}
}
private void resetUnreadBackground(){

View File

@@ -458,7 +458,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList
ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(100), V.dp(100)));
ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.header : account.headerStatic, 1000, 1000));
SpannableStringBuilder ssb=new SpannableStringBuilder(account.displayName);
HtmlParser.parseCustomEmoji(ssb, account.emojis);
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(ssb, account.emojis);
name.setText(ssb);
setTitle(ssb);

View File

@@ -1,761 +0,0 @@
package org.joinmastodon.android.fragments;
import android.animation.ObjectAnimator;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.animation.LinearInterpolator;
import android.widget.Button;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.Switch;
import android.widget.TextView;
import android.widget.Toast;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MainActivity;
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.oauth.RevokeOauthToken;
import org.joinmastodon.android.api.session.AccountActivationInfo;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
import org.joinmastodon.android.model.PushNotification;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import java.util.ArrayList;
import java.util.function.Consumer;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class SettingsFragment extends MastodonToolbarFragment{
private UsableRecyclerView list;
private ArrayList<Item> items=new ArrayList<>();
private ThemeItem themeItem;
private NotificationPolicyItem notificationPolicyItem;
private String accountID;
private boolean needUpdateNotificationSettings;
private PushSubscription pushSubscription;
private ImageView themeTransitionWindowView;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
setTitle(R.string.settings);
accountID=getArguments().getString("account");
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
if(GithubSelfUpdater.needSelfUpdating()){
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
GithubSelfUpdater.UpdateState state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING){
items.add(new UpdateItem());
}
}
items.add(new HeaderItem(R.string.settings_theme));
items.add(themeItem=new ThemeItem());
items.add(new SwitchItem(R.string.theme_true_black, R.drawable.ic_fluent_dark_theme_24_regular, GlobalUserPreferences.trueBlackTheme, this::onTrueBlackThemeChanged));
items.add(new HeaderItem(R.string.settings_behavior));
items.add(new SwitchItem(R.string.settings_gif, R.drawable.ic_fluent_gif_24_regular, GlobalUserPreferences.playGifs, i->{
GlobalUserPreferences.playGifs=i.checked;
GlobalUserPreferences.save();
}));
items.add(new SwitchItem(R.string.settings_custom_tabs, R.drawable.ic_fluent_link_24_regular, GlobalUserPreferences.useCustomTabs, i->{
GlobalUserPreferences.useCustomTabs=i.checked;
GlobalUserPreferences.save();
}));
items.add(new HeaderItem(R.string.settings_notifications));
items.add(notificationPolicyItem=new NotificationPolicyItem());
PushSubscription pushSubscription=getPushSubscription();
items.add(new SwitchItem(R.string.notify_favorites, R.drawable.ic_fluent_star_24_regular, pushSubscription.alerts.favourite, i->onNotificationsChanged(PushNotification.Type.FAVORITE, i.checked)));
items.add(new SwitchItem(R.string.notify_follow, R.drawable.ic_fluent_person_add_24_regular, pushSubscription.alerts.follow, i->onNotificationsChanged(PushNotification.Type.FOLLOW, i.checked)));
items.add(new SwitchItem(R.string.notify_reblog, R.drawable.ic_fluent_arrow_repeat_all_24_regular, pushSubscription.alerts.reblog, i->onNotificationsChanged(PushNotification.Type.REBLOG, i.checked)));
items.add(new SwitchItem(R.string.notify_mention, R.drawable.ic_at_symbol, pushSubscription.alerts.mention, i->onNotificationsChanged(PushNotification.Type.MENTION, i.checked)));
items.add(new HeaderItem(R.string.settings_boring));
items.add(new TextItem(R.string.settings_account, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/auth/edit")));
items.add(new TextItem(R.string.settings_contribute, ()->UiUtils.launchWebBrowser(getActivity(), "https://github.com/mastodon/mastodon-android")));
items.add(new TextItem(R.string.settings_tos, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")));
items.add(new TextItem(R.string.settings_privacy_policy, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+session.domain+"/terms")));
items.add(new RedHeaderItem(R.string.settings_spicy));
items.add(new TextItem(R.string.settings_clear_cache, this::clearImageCache));
items.add(new TextItem(R.string.log_out, this::confirmLogOut));
if(BuildConfig.DEBUG){
items.add(new RedHeaderItem("Debug options"));
items.add(new TextItem("Test e-mail confirmation flow", ()->{
AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID);
sess.activated=false;
sess.activationInfo=new AccountActivationInfo("test@email", System.currentTimeMillis());
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("debug", true);
Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args);
}));
}
items.add(new FooterItem(getString(R.string.settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)));
}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
if(themeTransitionWindowView!=null){
// Activity has finished recreating. Remove the overlay.
MastodonApp.context.getSystemService(WindowManager.class).removeView(themeTransitionWindowView);
themeTransitionWindowView=null;
}
}
@Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
list=new UsableRecyclerView(getActivity());
list.setLayoutManager(new LinearLayoutManager(getActivity()));
list.setAdapter(new SettingsAdapter());
list.setBackgroundColor(UiUtils.getThemeColor(getActivity(), android.R.attr.colorBackground));
list.setPadding(0, V.dp(16), 0, V.dp(12));
list.setClipToPadding(false);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
// Add 32dp gaps between sections
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if((holder instanceof HeaderViewHolder || holder instanceof FooterViewHolder) && holder.getAbsoluteAdapterPosition()>1)
outRect.top=V.dp(32);
}
});
return list;
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
list.setPadding(0, V.dp(16), 0, V.dp(12)+insets.getSystemWindowInsetBottom());
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
}
super.onApplyWindowInsets(insets);
}
@Override
public void onDestroy(){
super.onDestroy();
if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){
AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription);
}
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
if(GithubSelfUpdater.needSelfUpdating())
E.register(this);
}
@Override
public void onDestroyView(){
super.onDestroyView();
if(GithubSelfUpdater.needSelfUpdating())
E.unregister(this);
}
private void onThemePreferenceClick(GlobalUserPreferences.ThemePreference theme){
GlobalUserPreferences.theme=theme;
GlobalUserPreferences.save();
restartActivityToApplyNewTheme();
}
private void onTrueBlackThemeChanged(SwitchItem item){
GlobalUserPreferences.trueBlackTheme=item.checked;
GlobalUserPreferences.save();
RecyclerView.ViewHolder themeHolder=list.findViewHolderForAdapterPosition(items.indexOf(themeItem));
if(themeHolder!=null){
((ThemeViewHolder)themeHolder).bindSubitems();
}else{
list.getAdapter().notifyItemChanged(items.indexOf(themeItem));
}
if(UiUtils.isDarkTheme()){
restartActivityToApplyNewTheme();
}
}
private void restartActivityToApplyNewTheme(){
// Calling activity.recreate() causes a black screen for like half a second.
// So, let's take a screenshot and overlay it on top to create the illusion of a smoother transition.
// As a bonus, we can fade it out to make it even smoother.
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N){
View activityDecorView=getActivity().getWindow().getDecorView();
Bitmap bitmap=Bitmap.createBitmap(activityDecorView.getWidth(), activityDecorView.getHeight(), Bitmap.Config.ARGB_8888);
activityDecorView.draw(new Canvas(bitmap));
themeTransitionWindowView=new ImageView(MastodonApp.context);
themeTransitionWindowView.setImageBitmap(bitmap);
WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION);
lp.flags=WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE |
WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS;
lp.systemUiVisibility=View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
lp.systemUiVisibility|=(activityDecorView.getWindowSystemUiVisibility() & (View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR));
lp.width=lp.height=WindowManager.LayoutParams.MATCH_PARENT;
lp.token=getActivity().getWindow().getAttributes().token;
lp.windowAnimations=R.style.window_fade_out;
MastodonApp.context.getSystemService(WindowManager.class).addView(themeTransitionWindowView, lp);
}
getActivity().recreate();
}
private PushSubscription getPushSubscription(){
if(pushSubscription!=null)
return pushSubscription;
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
if(session.pushSubscription==null){
pushSubscription=new PushSubscription();
pushSubscription.alerts=PushSubscription.Alerts.ofAll();
}else{
pushSubscription=session.pushSubscription.clone();
}
return pushSubscription;
}
private void onNotificationsChanged(PushNotification.Type type, boolean enabled){
PushSubscription subscription=getPushSubscription();
switch(type){
case FAVORITE -> subscription.alerts.favourite=enabled;
case FOLLOW -> subscription.alerts.follow=enabled;
case REBLOG -> subscription.alerts.reblog=enabled;
case MENTION -> subscription.alerts.mention=subscription.alerts.poll=enabled;
}
needUpdateNotificationSettings=true;
}
private void onNotificationsPolicyChanged(PushSubscription.Policy policy){
PushSubscription subscription=getPushSubscription();
PushSubscription.Policy prevPolicy=subscription.policy;
if(prevPolicy==policy)
return;
subscription.policy=policy;
int index=items.indexOf(notificationPolicyItem);
RecyclerView.ViewHolder policyHolder=list.findViewHolderForAdapterPosition(index);
if(policyHolder!=null){
((NotificationPolicyViewHolder)policyHolder).rebind();
}else{
list.getAdapter().notifyItemChanged(index);
}
if((prevPolicy==PushSubscription.Policy.NONE)!=(policy==PushSubscription.Policy.NONE)){
index++;
while(items.get(index) instanceof SwitchItem si){
si.enabled=si.checked=policy!=PushSubscription.Policy.NONE;
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(index);
if(holder!=null)
((BindableViewHolder<?>)holder).rebind();
else
list.getAdapter().notifyItemChanged(index);
index++;
}
}
needUpdateNotificationSettings=true;
}
private void confirmLogOut(){
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.log_out)
.setMessage(R.string.confirm_log_out)
.setPositiveButton(R.string.log_out, (dialog, which) -> logOut())
.setNegativeButton(R.string.cancel, null)
.show();
}
private void logOut(){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
onLoggedOut();
}
@Override
public void onError(ErrorResponse error){
onLoggedOut();
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}
private void onLoggedOut(){
AccountSessionManager.getInstance().removeAccount(accountID);
getActivity().finish();
Intent intent=new Intent(getActivity(), MainActivity.class);
startActivity(intent);
}
private void clearImageCache(){
MastodonAPIController.runInBackground(()->{
Activity activity=getActivity();
ImageCache.getInstance(getActivity()).clear();
Toast.makeText(activity, R.string.media_cache_cleared, Toast.LENGTH_SHORT).show();
});
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
if(items.get(0) instanceof UpdateItem item){
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(0);
if(holder instanceof UpdateViewHolder uvh){
uvh.bind(item);
}
}
}
private static abstract class Item{
public abstract int getViewType();
}
private class HeaderItem extends Item{
private String text;
public HeaderItem(@StringRes int text){
this.text=getString(text);
}
public HeaderItem(String text){
this.text=text;
}
@Override
public int getViewType(){
return 0;
}
}
private class SwitchItem extends Item{
private String text;
private int icon;
private boolean checked;
private Consumer<SwitchItem> onChanged;
private boolean enabled=true;
public SwitchItem(@StringRes int text, @DrawableRes int icon, boolean checked, Consumer<SwitchItem> onChanged){
this.text=getString(text);
this.icon=icon;
this.checked=checked;
this.onChanged=onChanged;
}
public SwitchItem(@StringRes int text, int icon, boolean checked, Consumer<SwitchItem> onChanged, boolean enabled){
this.text=getString(text);
this.icon=icon;
this.checked=checked;
this.onChanged=onChanged;
this.enabled=enabled;
}
@Override
public int getViewType(){
return 1;
}
}
private static class ThemeItem extends Item{
@Override
public int getViewType(){
return 2;
}
}
private static class NotificationPolicyItem extends Item{
@Override
public int getViewType(){
return 3;
}
}
private class TextItem extends Item{
private String text;
private Runnable onClick;
public TextItem(@StringRes int text, Runnable onClick){
this.text=getString(text);
this.onClick=onClick;
}
public TextItem(String text, Runnable onClick){
this.text=text;
this.onClick=onClick;
}
@Override
public int getViewType(){
return 4;
}
}
private class RedHeaderItem extends HeaderItem{
public RedHeaderItem(int text){
super(text);
}
public RedHeaderItem(String text){
super(text);
}
@Override
public int getViewType(){
return 5;
}
}
private class FooterItem extends Item{
private String text;
public FooterItem(String text){
this.text=text;
}
@Override
public int getViewType(){
return 6;
}
}
private class UpdateItem extends Item{
@Override
public int getViewType(){
return 7;
}
}
private class SettingsAdapter extends RecyclerView.Adapter<BindableViewHolder<Item>>{
@NonNull
@Override
public BindableViewHolder<Item> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
//noinspection unchecked
return (BindableViewHolder<Item>) switch(viewType){
case 0 -> new HeaderViewHolder(false);
case 1 -> new SwitchViewHolder();
case 2 -> new ThemeViewHolder();
case 3 -> new NotificationPolicyViewHolder();
case 4 -> new TextViewHolder();
case 5 -> new HeaderViewHolder(true);
case 6 -> new FooterViewHolder();
case 7 -> new UpdateViewHolder();
default -> throw new IllegalStateException("Unexpected value: "+viewType);
};
}
@Override
public void onBindViewHolder(@NonNull BindableViewHolder<Item> holder, int position){
holder.bind(items.get(position));
}
@Override
public int getItemCount(){
return items.size();
}
@Override
public int getItemViewType(int position){
return items.get(position).getViewType();
}
}
private class HeaderViewHolder extends BindableViewHolder<HeaderItem>{
private final TextView text;
public HeaderViewHolder(boolean red){
super(getActivity(), R.layout.item_settings_header, list);
text=(TextView) itemView;
if(red)
text.setTextColor(getResources().getColor(UiUtils.isDarkTheme() ? R.color.error_400 : R.color.error_700));
}
@Override
public void onBind(HeaderItem item){
text.setText(item.text);
}
}
private class SwitchViewHolder extends BindableViewHolder<SwitchItem> implements UsableRecyclerView.DisableableClickable{
private final TextView text;
private final ImageView icon;
private final Switch checkbox;
public SwitchViewHolder(){
super(getActivity(), R.layout.item_settings_switch, list);
text=findViewById(R.id.text);
icon=findViewById(R.id.icon);
checkbox=findViewById(R.id.checkbox);
}
@Override
public void onBind(SwitchItem item){
text.setText(item.text);
icon.setImageResource(item.icon);
checkbox.setChecked(item.checked && item.enabled);
checkbox.setEnabled(item.enabled);
}
@Override
public void onClick(){
item.checked=!item.checked;
checkbox.setChecked(item.checked);
item.onChanged.accept(item);
}
@Override
public boolean isEnabled(){
return item.enabled;
}
}
private class ThemeViewHolder extends BindableViewHolder<ThemeItem>{
private SubitemHolder autoHolder, lightHolder, darkHolder;
public ThemeViewHolder(){
super(getActivity(), R.layout.item_settings_theme, list);
autoHolder=new SubitemHolder(findViewById(R.id.theme_auto));
lightHolder=new SubitemHolder(findViewById(R.id.theme_light));
darkHolder=new SubitemHolder(findViewById(R.id.theme_dark));
}
@Override
public void onBind(ThemeItem item){
bindSubitems();
}
public void bindSubitems(){
autoHolder.bind(R.string.theme_auto, GlobalUserPreferences.trueBlackTheme ? R.drawable.theme_auto_trueblack : R.drawable.theme_auto, GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.AUTO);
lightHolder.bind(R.string.theme_light, R.drawable.theme_light, GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.LIGHT);
darkHolder.bind(R.string.theme_dark, GlobalUserPreferences.trueBlackTheme ? R.drawable.theme_dark_trueblack : R.drawable.theme_dark, GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.DARK);
}
private void onSubitemClick(View v){
GlobalUserPreferences.ThemePreference pref;
if(v.getId()==R.id.theme_auto)
pref=GlobalUserPreferences.ThemePreference.AUTO;
else if(v.getId()==R.id.theme_light)
pref=GlobalUserPreferences.ThemePreference.LIGHT;
else if(v.getId()==R.id.theme_dark)
pref=GlobalUserPreferences.ThemePreference.DARK;
else
return;
onThemePreferenceClick(pref);
}
private class SubitemHolder{
public TextView text;
public ImageView icon;
public RadioButton checkbox;
public SubitemHolder(View view){
text=view.findViewById(R.id.text);
icon=view.findViewById(R.id.icon);
checkbox=view.findViewById(R.id.checkbox);
view.setOnClickListener(ThemeViewHolder.this::onSubitemClick);
icon.setClipToOutline(true);
icon.setOutlineProvider(OutlineProviders.roundedRect(4));
}
public void bind(int text, int icon, boolean checked){
this.text.setText(text);
this.icon.setImageResource(icon);
checkbox.setChecked(checked);
}
public void setChecked(boolean checked){
checkbox.setChecked(checked);
}
}
}
private class NotificationPolicyViewHolder extends BindableViewHolder<NotificationPolicyItem>{
private final Button button;
private final PopupMenu popupMenu;
@SuppressLint("ClickableViewAccessibility")
public NotificationPolicyViewHolder(){
super(getActivity(), R.layout.item_settings_notification_policy, list);
button=findViewById(R.id.button);
popupMenu=new PopupMenu(getActivity(), button, Gravity.CENTER_HORIZONTAL);
popupMenu.inflate(R.menu.notification_policy);
popupMenu.setOnMenuItemClickListener(item->{
PushSubscription.Policy policy;
int id=item.getItemId();
if(id==R.id.notify_anyone)
policy=PushSubscription.Policy.ALL;
else if(id==R.id.notify_followed)
policy=PushSubscription.Policy.FOLLOWED;
else if(id==R.id.notify_follower)
policy=PushSubscription.Policy.FOLLOWER;
else if(id==R.id.notify_none)
policy=PushSubscription.Policy.NONE;
else
return false;
onNotificationsPolicyChanged(policy);
return true;
});
UiUtils.enablePopupMenuIcons(getActivity(), popupMenu);
button.setOnTouchListener(popupMenu.getDragToOpenListener());
button.setOnClickListener(v->popupMenu.show());
}
@Override
public void onBind(NotificationPolicyItem item){
button.setText(switch(getPushSubscription().policy){
case ALL -> R.string.notify_anyone;
case FOLLOWED -> R.string.notify_followed;
case FOLLOWER -> R.string.notify_follower;
case NONE -> R.string.notify_none;
});
}
}
private class TextViewHolder extends BindableViewHolder<TextItem> implements UsableRecyclerView.Clickable{
private final TextView text;
public TextViewHolder(){
super(getActivity(), R.layout.item_settings_text, list);
text=(TextView) itemView;
}
@Override
public void onBind(TextItem item){
text.setText(item.text);
}
@Override
public void onClick(){
item.onClick.run();
}
}
private class FooterViewHolder extends BindableViewHolder<FooterItem>{
private final TextView text;
public FooterViewHolder(){
super(getActivity(), R.layout.item_settings_footer, list);
text=(TextView) itemView;
}
@Override
public void onBind(FooterItem item){
text.setText(item.text);
}
}
private class UpdateViewHolder extends BindableViewHolder<UpdateItem>{
private final TextView text;
private final Button button;
private final ImageButton cancelBtn;
private final ProgressBar progress;
private ObjectAnimator rotationAnimator;
private Runnable progressUpdater=this::updateProgress;
public UpdateViewHolder(){
super(getActivity(), R.layout.item_settings_update, list);
text=findViewById(R.id.text);
button=findViewById(R.id.button);
cancelBtn=findViewById(R.id.cancel_btn);
progress=findViewById(R.id.progress);
button.setOnClickListener(v->{
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
switch(updater.getState()){
case UPDATE_AVAILABLE -> updater.downloadUpdate();
case DOWNLOADED -> updater.installUpdate(getActivity());
}
});
cancelBtn.setOnClickListener(v->GithubSelfUpdater.getInstance().cancelDownload());
rotationAnimator=ObjectAnimator.ofFloat(progress, View.ROTATION, 0f, 360f);
rotationAnimator.setInterpolator(new LinearInterpolator());
rotationAnimator.setDuration(1500);
rotationAnimator.setRepeatCount(ObjectAnimator.INFINITE);
}
@Override
public void onBind(UpdateItem item){
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
GithubSelfUpdater.UpdateInfo info=updater.getUpdateInfo();
GithubSelfUpdater.UpdateState state=updater.getState();
if(state!=GithubSelfUpdater.UpdateState.DOWNLOADED){
text.setText(getString(R.string.update_available, info.version));
button.setText(getString(R.string.download_update, UiUtils.formatFileSize(getActivity(), info.size, false)));
}else{
text.setText(getString(R.string.update_ready, info.version));
button.setText(R.string.install_update);
}
if(state==GithubSelfUpdater.UpdateState.DOWNLOADING){
rotationAnimator.start();
button.setVisibility(View.INVISIBLE);
cancelBtn.setVisibility(View.VISIBLE);
progress.setVisibility(View.VISIBLE);
updateProgress();
}else{
rotationAnimator.cancel();
button.setVisibility(View.VISIBLE);
cancelBtn.setVisibility(View.GONE);
progress.setVisibility(View.GONE);
progress.removeCallbacks(progressUpdater);
}
}
private void updateProgress(){
GithubSelfUpdater updater=GithubSelfUpdater.getInstance();
if(updater.getState()!=GithubSelfUpdater.UpdateState.DOWNLOADING)
return;
int value=Math.round(progress.getMax()*updater.getDownloadProgress());
if(Build.VERSION.SDK_INT>=24)
progress.setProgress(value, true);
else
progress.setProgress(value);
progress.postDelayed(progressUpdater, 1000);
}
}
}

View File

@@ -1,8 +1,6 @@
package org.joinmastodon.android.fragments;
import android.content.res.ColorStateList;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Bundle;
import android.view.View;
import android.view.ViewGroup;
@@ -13,7 +11,8 @@ 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.FilterContext;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusContext;
import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem;
@@ -47,7 +46,10 @@ public class ThreadFragment extends StatusListFragment{
knownAccounts.put(inReplyToAccount.id, inReplyToAccount);
data.add(mainStatus);
onAppendItems(Collections.singletonList(mainStatus));
setTitle(HtmlParser.parseCustomEmoji(getString(R.string.post_from_user, mainStatus.account.displayName), mainStatus.account.emojis));
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
setTitle(HtmlParser.parseCustomEmoji(getString(R.string.post_from_user, mainStatus.account.displayName), mainStatus.account.emojis));
else
setTitle(getString(R.string.post_from_user, mainStatus.account.displayName));
}
@Override
@@ -102,11 +104,11 @@ public class ThreadFragment extends StatusListFragment{
}
private List<Status> filterStatuses(List<Status> statuses){
List<Filter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.THREAD)).collect(Collectors.toList());
List<LegacyFilter> filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(FilterContext.THREAD)).collect(Collectors.toList());
if(filters.isEmpty())
return statuses;
return statuses.stream().filter(status->{
for(Filter filter:filters){
for(LegacyFilter filter:filters){
if(filter.matches(status))
return false;
}

View File

@@ -95,7 +95,7 @@ public class ComposeAccountSearchFragment extends BaseAccountListFragment{
@Override
public void onSuccess(SearchResults result){
setEmptyText(R.string.no_search_results);
onDataLoaded(result.accounts.stream().map(AccountViewModel::new).collect(Collectors.toList()), false);
onDataLoaded(result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), false);
}
})
.exec(accountID);

View File

@@ -24,7 +24,7 @@ public abstract class PaginatedAccountListFragment extends BaseAccountListFragme
nextMaxID=result.nextPageUri.getQueryParameter("max_id");
else
nextMaxID=null;
onDataLoaded(result.stream().map(AccountViewModel::new).collect(Collectors.toList()), nextMaxID!=null);
onDataLoaded(result.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList()), nextMaxID!=null);
}
})
.exec(accountID);

View File

@@ -5,7 +5,7 @@ import android.view.View;
import org.joinmastodon.android.api.requests.timelines.GetPublicTimeline;
import org.joinmastodon.android.fragments.StatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.utils.DiscoverInfoBannerHelper;
import org.joinmastodon.android.utils.StatusFilterPredicate;
@@ -27,7 +27,7 @@ public class LocalTimelineFragment extends StatusListFragment{
public void onSuccess(List<Status> result){
if(!result.isEmpty())
maxID=result.get(result.size()-1).id;
onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, Filter.FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty());
onDataLoaded(result.stream().filter(new StatusFilterPredicate(accountID, FilterContext.PUBLIC)).collect(Collectors.toList()), !result.isEmpty());
}
})
.exec(accountID);

View File

@@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments.onboarding;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@@ -23,8 +22,7 @@ import org.joinmastodon.android.api.requests.accounts.UpdateAccountCredentials;
import org.joinmastodon.android.api.session.AccountActivationInfo;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.SettingsFragment;
import org.joinmastodon.android.fragments.settings.SettingsMainFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.AccountSwitcherSheet;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -38,7 +36,6 @@ import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.utils.V;
public class AccountActivationFragment extends ToolbarFragment{
private String accountID;
@@ -70,7 +67,7 @@ public class AccountActivationFragment extends ToolbarFragment{
openEmailBtn.setOnLongClickListener(v->{
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), SettingsFragment.class, args);
Nav.go(getActivity(), SettingsMainFragment.class, args);
return true;
});
resendBtn=view.findViewById(R.id.btn_resend);

View File

@@ -1,8 +1,6 @@
package org.joinmastodon.android.fragments.onboarding;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.os.Build;
import android.os.Bundle;
import android.text.Html;
import android.view.LayoutInflater;
@@ -15,20 +13,16 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.adapters.InstanceRulesAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.utils.ElevationOnScrollListener;
import org.parceler.Parcels;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
import me.grishka.appkit.views.UsableRecyclerView;
@@ -68,9 +62,8 @@ public class InstanceRulesFragment extends ToolbarFragment{
adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(headerView));
adapter.addAdapter(new ItemsAdapter());
adapter.addAdapter(new InstanceRulesAdapter(instance.rules));
list.setAdapter(adapter);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3SurfaceVariant, 1, 56, 0, DividerItemDecoration.NOT_FIRST));
btn=view.findViewById(R.id.btn_next);
btn.setOnClickListener(v->onButtonClick());
@@ -113,43 +106,4 @@ public class InstanceRulesFragment extends ToolbarFragment{
public void onApplyWindowInsets(WindowInsets insets){
super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(buttonBar, insets));
}
private class ItemsAdapter extends RecyclerView.Adapter<ItemViewHolder>{
@NonNull
@Override
public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new ItemViewHolder();
}
@Override
public void onBindViewHolder(@NonNull ItemViewHolder holder, int position){
holder.bind(instance.rules.get(position));
}
@Override
public int getItemCount(){
return instance.rules.size();
}
}
private class ItemViewHolder extends BindableViewHolder<Instance.Rule>{
private final TextView text, number;
public ItemViewHolder(){
super(getActivity(), R.layout.item_server_rule, list);
text=findViewById(R.id.text);
number=findViewById(R.id.number);
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(Instance.Rule item){
if(item.parsedText==null){
item.parsedText=HtmlParser.parseLinks(item.text);
}
text.setText(item.parsedText);
number.setText(String.format("%d", getAbsoluteAdapterPosition()));
}
}
}

View File

@@ -22,8 +22,8 @@ import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.model.FollowSuggestion;
import org.joinmastodon.android.model.ParsedAccount;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ProgressBarButton;
@@ -52,7 +52,7 @@ import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.FragmentRootLinearLayout;
import me.grishka.appkit.views.UsableRecyclerView;
public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment<ParsedAccount>{
public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment<AccountViewModel>{
private String accountID;
private Map<String, Relationship> relationships=Collections.emptyMap();
private GetAccountRelationships relationshipsRequest;
@@ -97,7 +97,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment<Pa
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<FollowSuggestion> result){
onDataLoaded(result.stream().map(fs->new ParsedAccount(fs.account, accountID)).collect(Collectors.toList()), false);
onDataLoaded(result.stream().map(fs->new AccountViewModel(fs.account, accountID)).collect(Collectors.toList()), false);
loadRelationships();
}
})
@@ -146,7 +146,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment<Pa
return;
}
ArrayList<String> accountIdsToFollow=new ArrayList<>();
for(ParsedAccount acc:data){
for(AccountViewModel acc:data){
Relationship rel=relationships.get(acc.account.id);
if(rel==null)
continue;
@@ -239,14 +239,14 @@ public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment<Pa
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
ParsedAccount account=data.get(position);
AccountViewModel account=data.get(position);
if(image==0)
return account.avatarRequest;
return account.avaRequest;
return account.emojiHelper.getImageRequest(image-1);
}
}
private class SuggestionViewHolder extends BindableViewHolder<ParsedAccount> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private class SuggestionViewHolder extends BindableViewHolder<AccountViewModel> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private final TextView name, username, bio;
private final ImageView avatar;
private final ProgressBarButton actionButton;
@@ -271,7 +271,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment<Pa
}
@Override
public void onBind(ParsedAccount item){
public void onBind(AccountViewModel item){
name.setText(item.parsedName);
username.setText(item.account.getDisplayUsername());
if(TextUtils.isEmpty(item.parsedBio)){

View File

@@ -0,0 +1,87 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.WindowInsets;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
import org.joinmastodon.android.ui.viewholders.CheckableListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.V;
public abstract class BaseSettingsFragment<T> extends MastodonRecyclerFragment<ListItem<T>>{
protected GenericListItemsAdapter<T> itemsAdapter;
protected String accountID;
public BaseSettingsFragment(){
super(20);
}
public BaseSettingsFragment(int perPage){
super(perPage);
}
public BaseSettingsFragment(int layout, int perPage){
super(layout, perPage);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setRetainInstance(true);
accountID=getArguments().getString("account");
setRefreshEnabled(false);
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
return itemsAdapter=new GenericListItemsAdapter<T>(data);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorM3OutlineVariant, 1, 0, 0, vh->vh instanceof SimpleListItemViewHolder ivh && ivh.getItem().dividerAfter));
list.setItemAnimator(new BetterItemAnimator());
}
protected int indexOfItemsAdapter(){
return 0;
}
protected void toggleCheckableItem(CheckableListItem<T> item){
item.toggle();
rebindItem(item);
}
protected void rebindItem(ListItem<T> item){
if(list==null)
return;
if(list.findViewHolderForAdapterPosition(indexOfItemsAdapter()+data.indexOf(item)) instanceof ListItemViewHolder<?> holder){
holder.rebind();
}
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
list.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
emptyView.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
progress.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
}else{
list.setPadding(0, 0, 0, 0);
}
super.onApplyWindowInsets(insets);
}
}

View File

@@ -0,0 +1,324 @@
package org.joinmastodon.android.fragments.settings;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.DatePicker;
import android.widget.EditText;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.api.requests.filters.CreateFilter;
import org.joinmastodon.android.api.requests.filters.DeleteFilter;
import org.joinmastodon.android.api.requests.filters.UpdateFilter;
import org.joinmastodon.android.events.SettingsFilterCreatedOrUpdatedEvent;
import org.joinmastodon.android.events.SettingsFilterDeletedEvent;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterKeyword;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
import org.parceler.Parcels;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
public class EditFilterFragment extends BaseSettingsFragment<Void> implements OnBackPressedListener{
private static final int WORDS_RESULT=370;
private static final int CONTEXT_RESULT=651;
private Filter filter;
private ListItem<Void> durationItem, wordsItem, contextItem;
private CheckableListItem<Void> cwItem;
private FloatingHintEditTextLayout titleEditLayout;
private EditText titleEdit;
private Instant endsAt;
private ArrayList<FilterKeyword> keywords=new ArrayList<>();
private ArrayList<String> deletedWordIDs=new ArrayList<>();
private EnumSet<FilterContext> context=EnumSet.allOf(FilterContext.class);
private boolean dirty;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
filter=Parcels.unwrap(getArguments().getParcelable("filter"));
setTitle(filter==null ? R.string.settings_add_filter : R.string.settings_edit_filter);
onDataLoaded(List.of(
durationItem=new ListItem<>(R.string.settings_filter_duration, 0, this::onDurationClick),
wordsItem=new ListItem<>(R.string.settings_filter_muted_words, 0, this::onWordsClick),
contextItem=new ListItem<>(R.string.settings_filter_context, 0, this::onContextClick),
cwItem=new CheckableListItem<>(R.string.settings_filter_show_cw, R.string.settings_filter_show_cw_explanation, CheckableListItem.Style.SWITCH, filter==null || filter.filterAction==FilterAction.WARN, ()->toggleCheckableItem(cwItem))
));
if(filter!=null){
endsAt=filter.expiresAt;
keywords.addAll(filter.keywords);
context=filter.context;
data.add(new ListItem<>(R.string.settings_delete_filter, 0, this::onDeleteClick, R.attr.colorM3Error, false));
}
updateDurationItem();
updateWordsItem();
updateContextItem();
setHasOptionsMenu(true);
setRetainInstance(true);
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
titleEditLayout=(FloatingHintEditTextLayout) getActivity().getLayoutInflater().inflate(R.layout.floating_hint_edit_text, list, false);
titleEdit=titleEditLayout.findViewById(R.id.edit);
titleEdit.setHint(R.string.settings_filter_title);
titleEditLayout.updateHint();
if(filter!=null)
titleEdit.setText(filter.title);
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SingleViewRecyclerAdapter(titleEditLayout));
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
protected int indexOfItemsAdapter(){
return 1;
}
private void onDurationClick(){
int[] durationOptions={
1800,
3600,
6*3600,
12*3600,
24*3600,
3*24*3600,
7*24*3600
};
ArrayList<String> options=Arrays.stream(durationOptions).mapToObj(d->UiUtils.formatDuration(getActivity(), d)).collect(Collectors.toCollection(ArrayList<String>::new));
options.add(0, getString(R.string.filter_duration_forever));
options.add(getString(R.string.filter_duration_custom));
Instant[] newEnd={null};
AlertDialog alert=new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.settings_filter_duration_title)
.setSupportingText(endsAt==null ? null : getString(R.string.settings_filter_ends, UiUtils.formatRelativeTimestampAsMinutesAgo(getActivity(), endsAt, false)))
.setSingleChoiceItems(options.toArray(new String[0]), -1, (dlg, item)->{
AlertDialog a=(AlertDialog) dlg;
if(item==options.size()-1){ // custom
showCustomDurationAlert(date->{
if(date==null){
a.getListView().setItemChecked(item, false);
}else{
newEnd[0]=date;
a.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
}
});
}else{
if(item==0){
newEnd[0]=null;
}else{
newEnd[0]=Instant.now().plusSeconds(durationOptions[item-1]);
}
a.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
}
})
.setPositiveButton(R.string.ok, (dlg, item)->{
if(!Objects.equals(endsAt, newEnd[0])){
endsAt=newEnd[0];
updateDurationItem();
dirty=true;
}
})
.setNegativeButton(R.string.cancel, null)
.show();
alert.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
}
private void showCustomDurationAlert(Consumer<Instant> callback){
DatePicker picker=new DatePicker(getActivity());
picker.setMinDate(LocalDate.now().plusDays(1).atStartOfDay(ZoneId.systemDefault()).toEpochSecond()*1000L);
AlertDialog alert=new M3AlertDialogBuilder(getActivity())
.setView(picker)
.setPositiveButton(R.string.ok, (dlg, item)->{
((AlertDialog)dlg).setOnDismissListener(null);
callback.accept(LocalDate.of(picker.getYear(), picker.getMonth()+1, picker.getDayOfMonth()).atStartOfDay(ZoneId.systemDefault()).toInstant());
})
.setNegativeButton(R.string.cancel, null)
.show();
alert.setOnDismissListener(dialog->callback.accept(null));
}
private void onWordsClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelableArrayList("words", (ArrayList<? extends Parcelable>) keywords.stream().map(Parcels::wrap).collect(Collectors.toCollection(ArrayList::new)));
Nav.goForResult(getActivity(), FilterWordsFragment.class, args, WORDS_RESULT, this);
}
private void onContextClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putSerializable("context", context);
Nav.goForResult(getActivity(), FilterContextFragment.class, args, CONTEXT_RESULT, this);
}
private void onDeleteClick(){
AlertDialog alert=new M3AlertDialogBuilder(getActivity())
.setTitle(getString(R.string.settings_delete_filter_title, filter.title))
.setMessage(R.string.settings_delete_filter_confirmation)
.setPositiveButton(R.string.delete, (dlg, item)->deleteFilter())
.setNegativeButton(R.string.cancel, null)
.show();
alert.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error));
}
private void updateDurationItem(){
if(endsAt==null){
durationItem.subtitle=getString(R.string.filter_duration_forever);
}else{
durationItem.subtitle=getString(R.string.settings_filter_ends, UiUtils.formatRelativeTimestampAsMinutesAgo(getActivity(), endsAt, false));
}
rebindItem(durationItem);
}
private void updateWordsItem(){
wordsItem.subtitle=getResources().getQuantityString(R.plurals.settings_x_muted_words, keywords.size(), keywords.size());
rebindItem(wordsItem);
}
private void updateContextItem(){
List<String> values=context.stream().map(c->getString(c.getDisplayNameRes())).collect(Collectors.toList());
contextItem.subtitle=switch(values.size()){
case 0 -> null;
case 1 -> values.get(0);
case 2 -> getString(R.string.selection_2_options, values.get(0), values.get(1));
case 3 -> getString(R.string.selection_3_options, values.get(0), values.get(1), values.get(2));
default -> getString(R.string.selection_4_or_more, values.get(0), values.get(1), values.size()-2);
};
rebindItem(contextItem);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.settings_edit_filter, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
if(item.getItemId()==R.id.save){
saveFilter();
}
return true;
}
private void saveFilter(){
if(titleEdit.length()==0){
titleEditLayout.setErrorState(getString(R.string.required_form_field_blank));
return;
}
MastodonAPIRequest<Filter> req;
if(filter==null){
req=new CreateFilter(titleEdit.getText().toString(), context, cwItem.checked ? FilterAction.WARN : FilterAction.HIDE, endsAt==null ? 0 : (int)(endsAt.getEpochSecond()-Instant.now().getEpochSecond()), keywords);
}else{
req=new UpdateFilter(filter.id, titleEdit.getText().toString(), context, cwItem.checked ? FilterAction.WARN : FilterAction.HIDE, endsAt==null ? 0 : (int)(endsAt.getEpochSecond()-Instant.now().getEpochSecond()), keywords, deletedWordIDs);
}
req.setCallback(new Callback<>(){
@Override
public void onSuccess(Filter result){
E.post(new SettingsFilterCreatedOrUpdatedEvent(accountID, result));
Nav.finish(EditFilterFragment.this);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.saving, true)
.exec(accountID);
}
private void deleteFilter(){
new DeleteFilter(filter.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Void result){
E.post(new SettingsFilterDeletedEvent(accountID, filter.id));
Nav.finish(EditFilterFragment.this);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.deleting, false)
.exec(accountID);
}
@Override
public void onFragmentResult(int reqCode, boolean success, Bundle result){
if(success){
if(reqCode==CONTEXT_RESULT){
EnumSet<FilterContext> context=(EnumSet<FilterContext>) result.getSerializable("context");
if(!context.equals(this.context)){
this.context=context;
dirty=true;
updateContextItem();
}
}else if(reqCode==WORDS_RESULT){
ArrayList<FilterKeyword> old=new ArrayList<>(keywords);
keywords.clear();
result.getParcelableArrayList("words").stream().map(p->(FilterKeyword)Parcels.unwrap(p)).forEach(keywords::add);
if(!old.equals(keywords)){
dirty=true;
updateWordsItem();
}
deletedWordIDs.addAll(result.getStringArrayList("deleted"));
}
}
}
private boolean isDirty(){
return dirty || (filter!=null && !titleEdit.getText().toString().equals(filter.title));
}
@Override
public boolean onBackPressed(){
if(isDirty()){
UiUtils.showConfirmationAlert(getActivity(), R.string.discard_changes, 0, R.string.discard, ()->Nav.finish(this));
return true;
}
return false;
}
}

View File

@@ -0,0 +1,48 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.stream.Collectors;
import me.grishka.appkit.fragments.OnBackPressedListener;
public class FilterContextFragment extends BaseSettingsFragment<FilterContext> implements OnBackPressedListener{
private EnumSet<FilterContext> context;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_filter_context);
context=(EnumSet<FilterContext>) getArguments().getSerializable("context");
onDataLoaded(Arrays.stream(FilterContext.values()).map(c->{
CheckableListItem<FilterContext> item=new CheckableListItem<>(c.getDisplayNameRes(), 0, CheckableListItem.Style.CHECKBOX, context.contains(c), null);
item.parentObject=c;
item.isEnabled=true;
item.onClick=()->toggleCheckableItem(item);
return item;
}).collect(Collectors.toList()));
}
@Override
protected void doLoadData(int offset, int count){}
@Override
public boolean onBackPressed(){
context=EnumSet.noneOf(FilterContext.class);
for(ListItem<FilterContext> item:data){
if(((CheckableListItem<FilterContext>) item).checked)
context.add(item.parentObject);
}
Bundle args=new Bundle();
args.putSerializable("context", context);
setResult(true, args);
return false;
}
}

View File

@@ -0,0 +1,327 @@
package org.joinmastodon.android.fragments.settings;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.IntEvaluator;
import android.animation.ObjectAnimator;
import android.app.AlertDialog;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.InputType;
import android.view.ActionMode;
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.view.WindowInsets;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.FilterKeyword;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FloatingHintEditTextLayout;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import me.grishka.appkit.FragmentStackActivity;
import me.grishka.appkit.fragments.OnBackPressedListener;
import me.grishka.appkit.utils.V;
public class FilterWordsFragment extends BaseSettingsFragment<FilterKeyword> implements OnBackPressedListener{
private ImageButton fab;
private ActionMode actionMode;
private ArrayList<ListItem<FilterKeyword>> selectedItems=new ArrayList<>();
private ArrayList<String> deletedItemIDs=new ArrayList<>();
private MenuItem deleteItem;
public FilterWordsFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_filter_muted_words);
onDataLoaded(getArguments().getParcelableArrayList("words").stream().map(p->{
FilterKeyword word=Parcels.unwrap(p);
ListItem<FilterKeyword> item=new ListItem<>(word.keyword, null, null, word);
item.isEnabled=true;
item.onClick=()->onWordClick(item);
return item;
}).collect(Collectors.toList()));
setHasOptionsMenu(true);
}
@Override
protected void doLoadData(int offset, int count){}
private void onWordClick(ListItem<FilterKeyword> item){
showAlertForWord(item.parentObject);
}
private void onSelectionModeWordClick(CheckableListItem<FilterKeyword> item){
if(selectedItems.remove(item)){
item.checked=false;
}else{
item.checked=true;
selectedItems.add(item);
}
rebindItem(item);
updateActionModeTitle();
}
@Override
public boolean onBackPressed(){
Bundle result=new Bundle();
result.putParcelableArrayList("words", (ArrayList<? extends Parcelable>) data.stream().map(i->i.parentObject).map(Parcels::wrap).collect(Collectors.toCollection(ArrayList::new)));
result.putStringArrayList("deleted", deletedItemIDs);
setResult(true, result);
return false;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
fab=view.findViewById(R.id.fab);
fab.setImageResource(R.drawable.ic_add_24px);
fab.setContentDescription(getString(R.string.add_muted_word));
fab.setOnClickListener(v->onFabClick());
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
int fabInset=0;
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
fabInset=insets.getSystemWindowInsetBottom();
}
((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+fabInset;
super.onApplyWindowInsets(insets);
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){
inflater.inflate(R.menu.settings_filter_words, menu);
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
enterSelectionMode(item.getItemId()==R.id.select_all);
return true;
}
@Override
public boolean wantsLightStatusBar(){
if(actionMode!=null)
return UiUtils.isDarkTheme();
return super.wantsLightStatusBar();
}
private void onFabClick(){
showAlertForWord(null);
}
private void showAlertForWord(FilterKeyword word){
AlertDialog.Builder bldr=new M3AlertDialogBuilder(getActivity())
.setHelpText(R.string.filter_add_word_help)
.setTitle(word==null ? R.string.add_muted_word : R.string.edit_muted_word)
.setNegativeButton(R.string.cancel, null);
FloatingHintEditTextLayout editWrap=(FloatingHintEditTextLayout) bldr.getContext().getSystemService(LayoutInflater.class).inflate(R.layout.floating_hint_edit_text, null);
EditText edit=editWrap.findViewById(R.id.edit);
edit.setHint(R.string.filter_word_or_phrase);
edit.setInputType(InputType.TYPE_TEXT_VARIATION_FILTER);
editWrap.updateHint();
bldr.setView(editWrap)
.setPositiveButton(word==null ? R.string.add : R.string.save, null);
if(word!=null){
edit.setText(word.keyword);
bldr.setNeutralButton(R.string.delete, null);
}
AlertDialog alert=bldr.show();
if(word!=null){
Button deleteBtn=alert.getButton(AlertDialog.BUTTON_NEUTRAL);
deleteBtn.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error));
deleteBtn.setOnClickListener(v->confirmDeleteWords(Collections.singletonList(word), alert::dismiss));
}
Button saveBtn=alert.getButton(AlertDialog.BUTTON_POSITIVE);
saveBtn.setEnabled(false);
saveBtn.setOnClickListener(v->{
String input=edit.getText().toString();
for(ListItem<FilterKeyword> item:data){
if(item.parentObject.keyword.equalsIgnoreCase(input)){
editWrap.setErrorState(getString(R.string.filter_word_already_in_list));
return;
}
}
if(word==null){
FilterKeyword w=new FilterKeyword();
w.wholeWord=true;
w.keyword=input;
ListItem<FilterKeyword> item=new ListItem<>(w.keyword, null, null, w);
item.isEnabled=true;
item.onClick=()->onWordClick(item);
data.add(item);
itemsAdapter.notifyItemInserted(data.size()-1);
}else{
word.keyword=input;
word.wholeWord=true;
for(ListItem<FilterKeyword> item:data){
if(item.parentObject==word){
rebindItem(item);
break;
}
}
}
alert.dismiss();
});
edit.addTextChangedListener(new SimpleTextWatcher(e->saveBtn.setEnabled(e.length()>0)));
}
private void confirmDeleteWords(List<FilterKeyword> words, Runnable onConfirmed){
AlertDialog alert=new M3AlertDialogBuilder(getActivity())
.setTitle(words.size()==1 ? getString(R.string.settings_delete_filter_word, words.get(0).keyword) : getResources().getQuantityString(R.plurals.settings_delete_x_filter_words, words.size(), words.size()))
// .setMessage(R.string.settings_delete_filter_confirmation)
.setPositiveButton(R.string.delete, (dlg, item)->{
if(onConfirmed!=null)
onConfirmed.run();
removeWords(words);
})
.setNegativeButton(R.string.cancel, null)
.show();
alert.getButton(AlertDialog.BUTTON_POSITIVE).setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Error));
}
private void removeWords(List<FilterKeyword> words){
ArrayList<Integer> indexes=new ArrayList<>();
for(int i=0;i<data.size();i++){
if(words.contains(data.get(i).parentObject)){
indexes.add(0, i);
}
}
for(int index:indexes){
data.remove(index);
itemsAdapter.notifyItemRemoved(index);
}
for(FilterKeyword w:words){
if(w.id!=null)
deletedItemIDs.add(w.id);
}
}
private void enterSelectionMode(boolean selectAll){
if(actionMode!=null)
return;
V.setVisibilityAnimated(fab, View.GONE);
actionMode=getActivity().startActionMode(new ActionMode.Callback(){
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu){
ObjectAnimator anim=ObjectAnimator.ofInt(getActivity().getWindow(), "statusBarColor", elevationOnScrollListener.getCurrentStatusBarColor(), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary));
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.start();
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(FilterWordsFragment.this);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu){
mode.getMenuInflater().inflate(R.menu.settings_filter_words_action_mode, menu);
for(int i=0;i<menu.size();i++){
Drawable icon=menu.getItem(i).getIcon().mutate();
icon.setTint(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnPrimary));
menu.getItem(i).setIcon(icon);
}
deleteItem=menu.findItem(R.id.delete);
return true;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item){
if(item.getItemId()==R.id.delete){
confirmDeleteWords(selectedItems.stream().map(i->i.parentObject).collect(Collectors.toList()), ()->leaveSelectionMode(false));
}
return true;
}
@Override
public void onDestroyActionMode(ActionMode mode){
leaveSelectionMode(true);
ObjectAnimator anim=ObjectAnimator.ofInt(getActivity().getWindow(), "statusBarColor", UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary), elevationOnScrollListener.getCurrentStatusBarColor());
anim.setEvaluator(new IntEvaluator(){
@Override
public Integer evaluate(float fraction, Integer startValue, Integer endValue){
return UiUtils.alphaBlendColors(startValue, endValue, fraction);
}
});
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
getActivity().getWindow().setStatusBarColor(0);
}
});
anim.start();
((FragmentStackActivity) getActivity()).invalidateSystemBarColors(FilterWordsFragment.this);
}
});
selectedItems.clear();
for(int i=0;i<data.size();i++){
ListItem<FilterKeyword> item=data.get(i);
CheckableListItem<FilterKeyword> newItem=new CheckableListItem<>(item.title, null, CheckableListItem.Style.CHECKBOX, selectAll, null);
newItem.isEnabled=true;
newItem.onClick=()->onSelectionModeWordClick(newItem);
newItem.parentObject=item.parentObject;
if(selectAll)
selectedItems.add(newItem);
data.set(i, newItem);
}
itemsAdapter.notifyItemRangeChanged(0, data.size());
updateActionModeTitle();
}
private void leaveSelectionMode(boolean fromActionMode){
if(actionMode==null)
return;
ActionMode actionMode=this.actionMode;
this.actionMode=null;
if(!fromActionMode)
actionMode.finish();
V.setVisibilityAnimated(fab, View.VISIBLE);
selectedItems.clear();
for(int i=0;i<data.size();i++){
ListItem<FilterKeyword> item=data.get(i);
ListItem<FilterKeyword> newItem=new ListItem<>(item.title, null, null);
newItem.isEnabled=true;
newItem.onClick=()->onWordClick(newItem);
newItem.parentObject=item.parentObject;
data.set(i, newItem);
}
itemsAdapter.notifyItemRangeChanged(0, data.size());
}
private void updateActionModeTitle(){
actionMode.setTitle(getResources().getQuantityString(R.plurals.x_items_selected, selectedItems.size(), selectedItems.size()));
deleteItem.setEnabled(!selectedItems.isEmpty());
}
}

View File

@@ -0,0 +1,82 @@
package org.joinmastodon.android.fragments.settings;
import android.app.Activity;
import android.os.Bundle;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class SettingsAboutAppFragment extends BaseSettingsFragment<Void>{
private ListItem<Void> mediaCacheItem;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(getString(R.string.about_app, getString(R.string.app_name)));
AccountSession s=AccountSessionManager.get(accountID);
onDataLoaded(List.of(
new ListItem<>(R.string.settings_even_more, 0, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/auth/edit")),
new ListItem<>(R.string.settings_contribute, 0, ()->UiUtils.launchWebBrowser(getActivity(), getString(R.string.github_url))),
new ListItem<>(R.string.settings_tos, 0, ()->UiUtils.launchWebBrowser(getActivity(), "https://"+s.domain+"/terms")),
new ListItem<>(R.string.settings_privacy_policy, 0, ()->UiUtils.launchWebBrowser(getActivity(), getString(R.string.privacy_policy_url)), 0, true),
mediaCacheItem=new ListItem<>(R.string.settings_clear_cache, 0, this::onClearMediaCacheClick)
));
updateMediaCacheItem();
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(super.getAdapter());
TextView versionInfo=new TextView(getActivity());
versionInfo.setSingleLine();
versionInfo.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(32)));
versionInfo.setTextAppearance(R.style.m3_label_medium);
versionInfo.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3Outline));
versionInfo.setGravity(Gravity.CENTER);
versionInfo.setText(getString(R.string.settings_app_version, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
adapter.addAdapter(new SingleViewRecyclerAdapter(versionInfo));
return adapter;
}
private void onClearMediaCacheClick(){
MastodonAPIController.runInBackground(()->{
Activity activity=getActivity();
ImageCache.getInstance(getActivity()).clear();
activity.runOnUiThread(()->{
Toast.makeText(activity, R.string.media_cache_cleared, Toast.LENGTH_SHORT).show();
updateMediaCacheItem();
});
});
}
private void updateMediaCacheItem(){
long size=ImageCache.getInstance(getActivity()).getDiskCache().size();
mediaCacheItem.subtitle=UiUtils.formatFileSize(getActivity(), size, false);
mediaCacheItem.isEnabled=size>0;
rebindItem(mediaCacheItem);
}
}

View File

@@ -0,0 +1,83 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Preferences;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.viewcontrollers.ComposeLanguageAlertViewController;
import java.util.List;
import java.util.Locale;
public class SettingsBehaviorFragment extends BaseSettingsFragment<Void>{
private ListItem<Void> languageItem;
private CheckableListItem<Void> altTextItem, playGifsItem, customTabsItem, confirmUnfollowItem, confirmBoostItem, confirmDeleteItem;
private Locale postLanguage;
private ComposeLanguageAlertViewController.SelectedOption newPostLanguage;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_behavior);
AccountSession s=AccountSessionManager.get(accountID);
if(s.preferences!=null && s.preferences.postingDefaultLanguage!=null){
postLanguage=Locale.forLanguageTag(s.preferences.postingDefaultLanguage);
}
onDataLoaded(List.of(
languageItem=new ListItem<>(getString(R.string.default_post_language), postLanguage!=null ? postLanguage.getDisplayName(Locale.getDefault()) : null, R.drawable.ic_language_24px, this::onDefaultLanguageClick),
altTextItem=new CheckableListItem<>(R.string.settings_alt_text_reminders, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.altTextReminders, R.drawable.ic_alt_24px, ()->toggleCheckableItem(altTextItem)),
playGifsItem=new CheckableListItem<>(R.string.settings_gif, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.playGifs, R.drawable.ic_animation_24px, ()->toggleCheckableItem(playGifsItem)),
customTabsItem=new CheckableListItem<>(R.string.settings_custom_tabs, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.useCustomTabs, R.drawable.ic_open_in_browser_24px, ()->toggleCheckableItem(customTabsItem)),
confirmUnfollowItem=new CheckableListItem<>(R.string.settings_confirm_unfollow, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmUnfollow, R.drawable.ic_person_remove_24px, ()->toggleCheckableItem(confirmUnfollowItem)),
confirmBoostItem=new CheckableListItem<>(R.string.settings_confirm_boost, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmBoost, R.drawable.ic_repeat_24px, ()->toggleCheckableItem(confirmBoostItem)),
confirmDeleteItem=new CheckableListItem<>(R.string.settings_confirm_delete_post, 0, CheckableListItem.Style.SWITCH, GlobalUserPreferences.confirmDeletePost, R.drawable.ic_delete_24px, ()->toggleCheckableItem(confirmDeleteItem))
));
}
@Override
protected void doLoadData(int offset, int count){}
private void onDefaultLanguageClick(){
ComposeLanguageAlertViewController vc=new ComposeLanguageAlertViewController(getActivity(), null, new ComposeLanguageAlertViewController.SelectedOption(-1, postLanguage), null);
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.default_post_language)
.setView(vc.getView())
.setPositiveButton(R.string.ok, (dlg, which)->{
ComposeLanguageAlertViewController.SelectedOption opt=vc.getSelectedOption();
if(!opt.locale.equals(postLanguage)){
newPostLanguage=opt;
languageItem.subtitle=newPostLanguage.locale.getDisplayLanguage(Locale.getDefault());
rebindItem(languageItem);
}
})
.setNegativeButton(R.string.cancel, null)
.show();
}
@Override
protected void onHidden(){
super.onHidden();
GlobalUserPreferences.playGifs=playGifsItem.checked;
GlobalUserPreferences.useCustomTabs=customTabsItem.checked;
GlobalUserPreferences.altTextReminders=altTextItem.checked;
GlobalUserPreferences.confirmUnfollow=customTabsItem.checked;
GlobalUserPreferences.confirmBoost=confirmBoostItem.checked;
GlobalUserPreferences.confirmDeletePost=confirmDeleteItem.checked;
GlobalUserPreferences.save();
if(newPostLanguage!=null){
AccountSession s=AccountSessionManager.get(accountID);
if(s.preferences==null)
s.preferences=new Preferences();
s.preferences.postingDefaultLanguage=newPostLanguage.locale.toLanguageTag();
s.savePreferencesLater();
}
}
}

View File

@@ -0,0 +1,63 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import org.joinmastodon.android.api.session.AccountActivationInfo;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.HomeFragment;
import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import java.util.List;
import me.grishka.appkit.Nav;
public class SettingsDebugFragment extends BaseSettingsFragment<Void>{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle("Debug settings");
ListItem<Void> selfUpdateItem, resetUpdateItem;
onDataLoaded(List.of(
new ListItem<>("Test email confirmation flow", null, this::onTestEmailConfirmClick),
selfUpdateItem=new ListItem<>("Force self-update", null, this::onForceSelfUpdateClick),
resetUpdateItem=new ListItem<>("Reset self-updater", null, this::onResetUpdaterClick)
));
if(!GithubSelfUpdater.needSelfUpdating()){
resetUpdateItem.isEnabled=selfUpdateItem.isEnabled=false;
selfUpdateItem.subtitle="Self-updater is unavailable in this build flavor";
}
}
@Override
protected void doLoadData(int offset, int count){}
private void onTestEmailConfirmClick(){
AccountSession sess=AccountSessionManager.getInstance().getAccount(accountID);
sess.activated=false;
sess.activationInfo=new AccountActivationInfo("test@email", System.currentTimeMillis());
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("debug", true);
Nav.goClearingStack(getActivity(), AccountActivationFragment.class, args);
}
private void onForceSelfUpdateClick(){
GithubSelfUpdater.forceUpdate=true;
GithubSelfUpdater.getInstance().maybeCheckForUpdates();
restartUI();
}
private void onResetUpdaterClick(){
GithubSelfUpdater.getInstance().reset();
restartUI();
}
private void restartUI(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.goClearingStack(getActivity(), HomeFragment.class, args);
}
}

View File

@@ -0,0 +1,152 @@
package org.joinmastodon.android.fragments.settings;
import android.app.Activity;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageView;
import org.joinmastodon.android.E;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountLocalPreferences;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.StatusDisplaySettingsChangedEvent;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import java.util.List;
import java.util.stream.IntStream;
import me.grishka.appkit.FragmentStackActivity;
public class SettingsDisplayFragment extends BaseSettingsFragment<Void>{
private ImageView themeTransitionWindowView;
private ListItem<Void> themeItem;
private CheckableListItem<Void> showCWsItem, hideSensitiveMediaItem, interactionCountsItem, emojiInNamesItem;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_display);
AccountSession s=AccountSessionManager.get(accountID);
AccountLocalPreferences lp=s.getLocalPreferences();
onDataLoaded(List.of(
themeItem=new ListItem<>(R.string.settings_theme, getAppearanceValue(), R.drawable.ic_dark_mode_24px, this::onAppearanceClick),
showCWsItem=new CheckableListItem<>(R.string.settings_show_cws, 0, CheckableListItem.Style.SWITCH, lp.showCWs, R.drawable.ic_warning_24px, ()->toggleCheckableItem(showCWsItem)),
hideSensitiveMediaItem=new CheckableListItem<>(R.string.settings_hide_sensitive_media, 0, CheckableListItem.Style.SWITCH, lp.hideSensitiveMedia, R.drawable.ic_no_adult_content_24px, ()->toggleCheckableItem(hideSensitiveMediaItem)),
interactionCountsItem=new CheckableListItem<>(R.string.settings_show_interaction_counts, 0, CheckableListItem.Style.SWITCH, lp.showInteractionCounts, R.drawable.ic_social_leaderboard_24px, ()->toggleCheckableItem(interactionCountsItem)),
emojiInNamesItem=new CheckableListItem<>(R.string.settings_show_emoji_in_names, 0, CheckableListItem.Style.SWITCH, lp.customEmojiInNames, R.drawable.ic_emoticon_24px, ()->toggleCheckableItem(emojiInNamesItem))
));
}
@Override
protected void doLoadData(int offset, int count){}
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
if(themeTransitionWindowView!=null){
// Activity has finished recreating. Remove the overlay.
activity.getSystemService(WindowManager.class).removeView(themeTransitionWindowView);
themeTransitionWindowView=null;
}
}
@Override
protected void onHidden(){
super.onHidden();
AccountSession s=AccountSessionManager.get(accountID);
AccountLocalPreferences lp=s.getLocalPreferences();
lp.showCWs=showCWsItem.checked;
lp.hideSensitiveMedia=hideSensitiveMediaItem.checked;
lp.showInteractionCounts=interactionCountsItem.checked;
lp.customEmojiInNames=emojiInNamesItem.checked;
lp.save();
E.post(new StatusDisplaySettingsChangedEvent(accountID));
}
private int getAppearanceValue(){
return switch(GlobalUserPreferences.theme){
case AUTO -> R.string.theme_auto;
case LIGHT -> R.string.theme_light;
case DARK -> R.string.theme_dark;
};
}
private void onAppearanceClick(){
int selected=switch(GlobalUserPreferences.theme){
case LIGHT -> 0;
case DARK -> 1;
case AUTO -> 2;
};
int[] newSelected={selected};
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.settings_theme)
.setSingleChoiceItems((String[])IntStream.of(R.string.theme_light, R.string.theme_dark, R.string.theme_auto).mapToObj(this::getString).toArray(String[]::new),
selected, (dlg, item)->newSelected[0]=item)
.setPositiveButton(R.string.ok, (dlg, item)->{
GlobalUserPreferences.ThemePreference pref=switch(newSelected[0]){
case 0 -> GlobalUserPreferences.ThemePreference.LIGHT;
case 1 -> GlobalUserPreferences.ThemePreference.DARK;
case 2 -> GlobalUserPreferences.ThemePreference.AUTO;
default -> throw new IllegalStateException("Unexpected value: "+newSelected[0]);
};
if(pref!=GlobalUserPreferences.theme){
GlobalUserPreferences.ThemePreference prev=GlobalUserPreferences.theme;
GlobalUserPreferences.theme=pref;
GlobalUserPreferences.save();
themeItem.subtitleRes=getAppearanceValue();
rebindItem(themeItem);
maybeApplyNewThemeRightNow(prev);
}
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private void maybeApplyNewThemeRightNow(GlobalUserPreferences.ThemePreference prev){
boolean isCurrentDark=prev==GlobalUserPreferences.ThemePreference.DARK ||
(prev==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive());
boolean isNewDark=GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.DARK ||
(GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.AUTO && Build.VERSION.SDK_INT>=30 && getResources().getConfiguration().isNightModeActive());
if(isCurrentDark!=isNewDark){
restartActivityToApplyNewTheme();
}
}
private void restartActivityToApplyNewTheme(){
// Calling activity.recreate() causes a black screen for like half a second.
// So, let's take a screenshot and overlay it on top to create the illusion of a smoother transition.
// As a bonus, we can fade it out to make it even smoother.
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N && Build.VERSION.SDK_INT<Build.VERSION_CODES.S){
View activityDecorView=getActivity().getWindow().getDecorView();
Bitmap bitmap=Bitmap.createBitmap(activityDecorView.getWidth(), activityDecorView.getHeight(), Bitmap.Config.ARGB_8888);
activityDecorView.draw(new Canvas(bitmap));
themeTransitionWindowView=new ImageView(MastodonApp.context);
themeTransitionWindowView.setImageBitmap(bitmap);
WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION);
lp.flags=WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE |
WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS;
lp.systemUiVisibility=View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
lp.systemUiVisibility|=(activityDecorView.getWindowSystemUiVisibility() & (View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR));
lp.width=lp.height=WindowManager.LayoutParams.MATCH_PARENT;
lp.token=getActivity().getWindow().getAttributes().token;
lp.windowAnimations=R.style.window_fade_out;
MastodonApp.context.getSystemService(WindowManager.class).addView(themeTransitionWindowView, lp);
}
getActivity().recreate();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this);
}
}

View File

@@ -0,0 +1,112 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.filters.GetFilters;
import org.joinmastodon.android.events.SettingsFilterCreatedOrUpdatedEvent;
import org.joinmastodon.android.events.SettingsFilterDeletedEvent;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.adapters.GenericListItemsAdapter;
import org.parceler.Parcels;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class SettingsFiltersFragment extends BaseSettingsFragment<Filter>{
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_filters);
loadData();
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
protected void doLoadData(int offset, int count){
new GetFilters()
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Filter> result){
onDataLoaded(result.stream().map(f->makeListItem(f)).collect(Collectors.toList()));
}
})
.exec(accountID);
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(super.getAdapter());
adapter.addAdapter(new GenericListItemsAdapter<>(Collections.singletonList(
new ListItem<Void>(R.string.settings_add_filter, 0, R.drawable.ic_add_24px, this::onAddFilterClick)
)));
return adapter;
}
private void onFilterClick(ListItem<Filter> filter){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("filter", Parcels.wrap(filter.parentObject));
Nav.go(getActivity(), EditFilterFragment.class, args);
}
private void onAddFilterClick(){
Bundle args=new Bundle();
args.putString("account", accountID);
Nav.go(getActivity(), EditFilterFragment.class, args);
}
private ListItem<Filter> makeListItem(Filter f){
ListItem<Filter> item=new ListItem<>(f.title, getString(f.isActive() ? R.string.filter_active : R.string.filter_inactive), null, f);
item.onClick=()->onFilterClick(item);
item.isEnabled=true;
return item;
}
@Subscribe
public void onFilterDeleted(SettingsFilterDeletedEvent ev){
if(!ev.accountID.equals(accountID))
return;
for(int i=0;i<data.size();i++){
if(data.get(i).parentObject.id.equals(ev.filterID)){
data.remove(i);
itemsAdapter.notifyItemRemoved(i);
break;
}
}
}
@Subscribe
public void onFilterCreatedOrUpdated(SettingsFilterCreatedOrUpdatedEvent ev){
if(!ev.accountID.equals(accountID))
return;
for(ListItem<Filter> item:data){
if(item.parentObject.id.equals(ev.filter.id)){
item.parentObject=ev.filter;
item.title=ev.filter.title;
item.subtitle=getString(ev.filter.isActive() ? R.string.filter_active : R.string.filter_inactive);
rebindItem(item);
return;
}
}
data.add(makeListItem(ev.filter));
itemsAdapter.notifyItemInserted(data.size()-1);
}
}

View File

@@ -0,0 +1,213 @@
package org.joinmastodon.android.fragments.settings;
import android.content.Intent;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import com.squareup.otto.Subscribe;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.E;
import org.joinmastodon.android.MainActivity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
import java.util.List;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class SettingsMainFragment extends BaseSettingsFragment<Void>{
private boolean loggedOut;
private HideableSingleViewRecyclerAdapter bannerAdapter;
private Button updateButton1, updateButton2;
private TextView updateText;
private Runnable updateDownloadProgressUpdater=new Runnable(){
@Override
public void run(){
GithubSelfUpdater.UpdateState state=GithubSelfUpdater.getInstance().getState();
if(state==GithubSelfUpdater.UpdateState.DOWNLOADING){
updateButton1.setText(getString(R.string.downloading_update, Math.round(GithubSelfUpdater.getInstance().getDownloadProgress()*100f)));
list.postDelayed(this, 250);
}
}
};
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings);
setSubtitle(AccountSessionManager.get(accountID).getFullUsername());
onDataLoaded(List.of(
new ListItem<>(R.string.settings_behavior, 0, R.drawable.ic_settings_24px, this::onBehaviorClick),
new ListItem<>(R.string.settings_display, 0, R.drawable.ic_style_24px, this::onDisplayClick),
new ListItem<>(R.string.settings_filters, 0, R.drawable.ic_filter_alt_24px, this::onFiltersClick),
new ListItem<>(R.string.settings_notifications, 0, R.drawable.ic_notifications_24px, this::onNotificationsClick),
new ListItem<>(R.string.settings_privacy, 0, R.drawable.ic_lock_24px, this::onPrivacyClick, 0, true),
new ListItem<>(AccountSessionManager.get(accountID).domain, getString(R.string.settings_server_explanation), R.drawable.ic_dns_24px, this::onServerClick),
new ListItem<>(getString(R.string.about_app, getString(R.string.app_name)), null, R.drawable.ic_info_24px, this::onAboutClick, null, 0, true),
new ListItem<>(R.string.log_out, 0, R.drawable.ic_logout_24px, this::onLogOutClick, R.attr.colorM3Error, false)
));
if(BuildConfig.DEBUG || BuildConfig.BUILD_TYPE.equals("appcenterPrivateBeta")){
data.add(0, new ListItem<>("Debug settings", null, R.drawable.ic_settings_24px, ()->Nav.go(getActivity(), SettingsDebugFragment.class, makeFragmentArgs()), null, 0, true));
}
AccountSessionManager.get(accountID).reloadPreferences(null);
E.register(this);
}
@Override
public void onDestroy(){
super.onDestroy();
E.unregister(this);
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
UiUtils.setToolbarWithSubtitleAppearance(getToolbar());
}
@Override
protected void onHidden(){
super.onHidden();
if(!loggedOut)
AccountSessionManager.get(accountID).savePreferencesIfPending();
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
View banner=getActivity().getLayoutInflater().inflate(R.layout.item_settings_banner, list, false);
updateText=banner.findViewById(R.id.text);
TextView bannerTitle=banner.findViewById(R.id.title);
ImageView bannerIcon=banner.findViewById(R.id.icon);
updateButton1=banner.findViewById(R.id.button);
updateButton2=banner.findViewById(R.id.button2);
bannerAdapter=new HideableSingleViewRecyclerAdapter(banner);
bannerAdapter.setVisible(false);
updateButton1.setOnClickListener(this::onUpdateButtonClick);
updateButton2.setOnClickListener(this::onUpdateButtonClick);
bannerTitle.setText(R.string.app_update_ready);
bannerIcon.setImageResource(R.drawable.ic_apk_install_24px);
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(bannerAdapter);
adapter.addAdapter(super.getAdapter());
return adapter;
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
if(GithubSelfUpdater.needSelfUpdating()){
updateUpdateBanner();
}
}
private Bundle makeFragmentArgs(){
Bundle args=new Bundle();
args.putString("account", accountID);
return args;
}
private void onBehaviorClick(){
Nav.go(getActivity(), SettingsBehaviorFragment.class, makeFragmentArgs());
}
private void onDisplayClick(){
Nav.go(getActivity(), SettingsDisplayFragment.class, makeFragmentArgs());
}
private void onFiltersClick(){
Nav.go(getActivity(), SettingsFiltersFragment.class, makeFragmentArgs());
}
private void onNotificationsClick(){
Nav.go(getActivity(), SettingsNotificationsFragment.class, makeFragmentArgs());
}
private void onPrivacyClick(){
}
private void onServerClick(){
Nav.go(getActivity(), SettingsServerFragment.class, makeFragmentArgs());
}
private void onAboutClick(){
Nav.go(getActivity(), SettingsAboutAppFragment.class, makeFragmentArgs());
}
private void onLogOutClick(){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new M3AlertDialogBuilder(getActivity())
.setMessage(getString(R.string.confirm_log_out, session.getFullUsername()))
.setPositiveButton(R.string.log_out, (dialog, which)->AccountSessionManager.get(accountID).logOut(getActivity(), ()->{
loggedOut=true;
getActivity().finish();
Intent intent=new Intent(getActivity(), MainActivity.class);
startActivity(intent);
}))
.setNegativeButton(R.string.cancel, null)
.show();
}
@Subscribe
public void onSelfUpdateStateChanged(SelfUpdateStateChangedEvent ev){
updateUpdateBanner();
}
private void updateUpdateBanner(){
GithubSelfUpdater.UpdateState state=GithubSelfUpdater.getInstance().getState();
if(state==GithubSelfUpdater.UpdateState.NO_UPDATE || state==GithubSelfUpdater.UpdateState.CHECKING){
bannerAdapter.setVisible(false);
}else{
bannerAdapter.setVisible(true);
updateText.setText(getString(R.string.app_update_version, GithubSelfUpdater.getInstance().getUpdateInfo().version));
if(state==GithubSelfUpdater.UpdateState.UPDATE_AVAILABLE){
updateButton2.setVisibility(View.GONE);
updateButton1.setEnabled(true);
updateButton1.setText(getString(R.string.download_update, UiUtils.formatFileSize(getActivity(), GithubSelfUpdater.getInstance().getUpdateInfo().size, true)));
}else if(state==GithubSelfUpdater.UpdateState.DOWNLOADING){
updateButton2.setVisibility(View.VISIBLE);
updateButton2.setText(R.string.cancel);
updateButton1.setEnabled(false);
list.removeCallbacks(updateDownloadProgressUpdater);
updateDownloadProgressUpdater.run();
}else if(state==GithubSelfUpdater.UpdateState.DOWNLOADED){
updateButton2.setVisibility(View.GONE);
updateButton1.setEnabled(true);
updateButton1.setText(R.string.install_update);
}
}
}
private void onUpdateButtonClick(View v){
if(v.getId()==R.id.button){
GithubSelfUpdater.UpdateState state=GithubSelfUpdater.getInstance().getState();
if(state==GithubSelfUpdater.UpdateState.UPDATE_AVAILABLE){
GithubSelfUpdater.getInstance().downloadUpdate();
}else if(state==GithubSelfUpdater.UpdateState.DOWNLOADED){
GithubSelfUpdater.getInstance().installUpdate(getActivity());
}
}else if(v.getId()==R.id.button2){
GithubSelfUpdater.getInstance().cancelDownload();
}
}
}

View File

@@ -0,0 +1,286 @@
package org.joinmastodon.android.fragments.settings;
import android.app.AlertDialog;
import android.app.NotificationManager;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.provider.Settings;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.PushSubscriptionManager;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.PushSubscription;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
public class SettingsNotificationsFragment extends BaseSettingsFragment<Void>{
private PushSubscription pushSubscription;
private CheckableListItem<Void> pauseItem;
private ListItem<Void> policyItem;
private MergeRecyclerAdapter mergeAdapter;
private HideableSingleViewRecyclerAdapter bannerAdapter;
private ImageView bannerIcon;
private TextView bannerText;
private Button bannerButton;
private CheckableListItem<Void> mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem;
private List<CheckableListItem<Void>> typeItems;
private boolean needUpdateNotificationSettings;
private boolean notificationsAllowed=true;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setTitle(R.string.settings_notifications);
getPushSubscription();
onDataLoaded(List.of(
pauseItem=new CheckableListItem<>(getString(R.string.pause_all_notifications), getPauseItemSubtitle(), CheckableListItem.Style.SWITCH, false, R.drawable.ic_notifications_paused_24px, ()->onPauseNotificationsClick(false)),
policyItem=new ListItem<>(R.string.settings_notifications_policy, 0, R.drawable.ic_group_24px, this::onNotificationsPolicyClick),
mentionsItem=new CheckableListItem<>(R.string.notification_type_mentions_and_replies, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.mention, ()->toggleCheckableItem(mentionsItem)),
boostsItem=new CheckableListItem<>(R.string.notification_type_reblog, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.reblog, ()->toggleCheckableItem(boostsItem)),
favoritesItem=new CheckableListItem<>(R.string.notification_type_favorite, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.favourite, ()->toggleCheckableItem(favoritesItem)),
followersItem=new CheckableListItem<>(R.string.notification_type_follow, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.follow, ()->toggleCheckableItem(followersItem)),
pollsItem=new CheckableListItem<>(R.string.notification_type_poll, 0, CheckableListItem.Style.CHECKBOX, pushSubscription.alerts.poll, ()->toggleCheckableItem(pollsItem))
));
typeItems=List.of(mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem);
pauseItem.checkedChangeListener=checked->onPauseNotificationsClick(true);
updatePolicyItem(null);
updatePauseItem();
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected void onHidden(){
super.onHidden();
PushSubscription ps=getPushSubscription();
needUpdateNotificationSettings|=mentionsItem.checked!=ps.alerts.mention
|| boostsItem.checked!=ps.alerts.reblog
|| favoritesItem.checked!=ps.alerts.favourite
|| followersItem.checked!=ps.alerts.follow
|| pollsItem.checked!=ps.alerts.poll;
if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){
ps.alerts.mention=mentionsItem.checked;
ps.alerts.reblog=boostsItem.checked;
ps.alerts.favourite=favoritesItem.checked;
ps.alerts.follow=followersItem.checked;
ps.alerts.poll=pollsItem.checked;
AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription);
}
}
@Override
protected void onShown(){
super.onShown();
boolean allowed=areNotificationsAllowed();
PushSubscription ps=getPushSubscription();
if(allowed!=notificationsAllowed){
notificationsAllowed=allowed;
updateBanner();
pauseItem.isEnabled=allowed;
policyItem.isEnabled=allowed;
rebindItem(pauseItem);
rebindItem(policyItem);
for(CheckableListItem<Void> item:typeItems){
item.isEnabled=allowed && ps.policy!=PushSubscription.Policy.NONE;
rebindItem(item);
}
}
}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
View banner=getActivity().getLayoutInflater().inflate(R.layout.item_settings_banner, list, false);
bannerText=banner.findViewById(R.id.text);
bannerIcon=banner.findViewById(R.id.icon);
bannerButton=banner.findViewById(R.id.button);
bannerAdapter=new HideableSingleViewRecyclerAdapter(banner);
bannerAdapter.setVisible(false);
banner.findViewById(R.id.button2).setVisibility(View.GONE);
banner.findViewById(R.id.title).setVisibility(View.GONE);
mergeAdapter=new MergeRecyclerAdapter();
mergeAdapter.addAdapter(bannerAdapter);
mergeAdapter.addAdapter(super.getAdapter());
return mergeAdapter;
}
@Override
protected int indexOfItemsAdapter(){
return mergeAdapter.getPositionForAdapter(itemsAdapter);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
updateBanner();
}
private boolean areNotificationsAllowed(){
return Build.VERSION.SDK_INT<Build.VERSION_CODES.N || getActivity().getSystemService(NotificationManager.class).areNotificationsEnabled();
}
private PushSubscription getPushSubscription(){
if(pushSubscription!=null)
return pushSubscription;
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
if(session.pushSubscription==null){
pushSubscription=new PushSubscription();
pushSubscription.alerts=PushSubscription.Alerts.ofAll();
}else{
pushSubscription=session.pushSubscription.clone();
}
return pushSubscription;
}
private String getPauseItemSubtitle(){
return getString(R.string.pause_notifications_off);
}
private void resumePausedNotifications(){
AccountSessionManager.get(accountID).getLocalPreferences().setNotificationsPauseEndTime(0);
updatePauseItem();
}
private void openSystemNotificationSettings(){
Intent intent;
if(Build.VERSION.SDK_INT<Build.VERSION_CODES.O){
intent=new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", getActivity().getPackageName(), null));
}else{
intent=new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getActivity().getPackageName());
}
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
private void onPauseNotificationsClick(boolean fromSwitch){
long time=AccountSessionManager.get(accountID).getLocalPreferences().getNotificationsPauseEndTime();
if(time>System.currentTimeMillis() && fromSwitch){
resumePausedNotifications();
return;
}
int[] durationOptions={
1800,
3600,
6*3600,
12*3600,
24*3600,
3*24*3600,
7*24*3600
};
int[] selectedOption={0};
AlertDialog alert=new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.pause_all_notifications_title)
.setSupportingText(time>System.currentTimeMillis() ? getString(R.string.pause_notifications_ends, UiUtils.formatRelativeTimestampAsMinutesAgo(getActivity(), Instant.ofEpochMilli(time), false)) : null)
.setSingleChoiceItems((String[])Arrays.stream(durationOptions).mapToObj(d->UiUtils.formatDuration(getActivity(), d)).toArray(String[]::new), -1, (dlg, item)->{
if(selectedOption[0]==0){
((AlertDialog)dlg).getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
}
selectedOption[0]=durationOptions[item];
})
.setPositiveButton(R.string.ok, (dlg, item)->AccountSessionManager.get(accountID).getLocalPreferences().setNotificationsPauseEndTime(System.currentTimeMillis()+selectedOption[0]*1000L))
.setNegativeButton(R.string.cancel, null)
.show();
alert.setOnDismissListener(dialog->updatePauseItem());
alert.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
}
private void onNotificationsPolicyClick(){
String[] items=Stream.of(
R.string.notifications_policy_anyone,
R.string.notifications_policy_followed,
R.string.notifications_policy_follower,
R.string.notifications_policy_no_one
).map(this::getString).toArray(String[]::new);
int[] selectedItem={getPushSubscription().policy.ordinal()};
new M3AlertDialogBuilder(getActivity())
.setTitle(R.string.settings_notifications_policy)
.setSingleChoiceItems(items, selectedItem[0], (dlg, which)->selectedItem[0]=which)
.setPositiveButton(R.string.ok, (dlg, which)->{
PushSubscription.Policy prevValue=getPushSubscription().policy;
PushSubscription.Policy newValue=PushSubscription.Policy.values()[selectedItem[0]];
if(prevValue==newValue)
return;
getPushSubscription().policy=newValue;
updatePolicyItem(prevValue);
needUpdateNotificationSettings=true;
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private void updatePolicyItem(PushSubscription.Policy prevValue){
policyItem.subtitleRes=switch(getPushSubscription().policy){
case ALL -> R.string.notifications_policy_anyone;
case FOLLOWED -> R.string.notifications_policy_followed;
case FOLLOWER -> R.string.notifications_policy_follower;
case NONE -> R.string.notifications_policy_no_one;
};
rebindItem(policyItem);
if(pushSubscription.policy==PushSubscription.Policy.NONE || prevValue==PushSubscription.Policy.NONE){
for(CheckableListItem<Void> item:typeItems){
item.checked=item.isEnabled=prevValue==PushSubscription.Policy.NONE;
rebindItem(item);
}
}
}
private void updatePauseItem(){
long time=AccountSessionManager.get(accountID).getLocalPreferences().getNotificationsPauseEndTime();
if(time<System.currentTimeMillis()){
pauseItem.subtitle=getString(R.string.pause_notifications_off);
pauseItem.checked=false;
}else{
pauseItem.subtitle=getString(R.string.pause_notifications_ends, UiUtils.formatRelativeTimestampAsMinutesAgo(getActivity(), Instant.ofEpochMilli(time), false));
pauseItem.checked=true;
}
rebindItem(pauseItem);
updateBanner();
}
private void updateBanner(){
if(bannerAdapter==null)
return;
long pauseTime=AccountSessionManager.get(accountID).getLocalPreferences().getNotificationsPauseEndTime();
if(!areNotificationsAllowed()){
bannerAdapter.setVisible(true);
bannerIcon.setImageResource(R.drawable.ic_app_badging_24px);
bannerText.setText(R.string.notifications_disabled_in_system);
bannerButton.setText(R.string.open_system_notification_settings);
bannerButton.setOnClickListener(v->openSystemNotificationSettings());
}else if(pauseTime>System.currentTimeMillis()){
bannerAdapter.setVisible(true);
bannerIcon.setImageResource(R.drawable.ic_notifications_paused_24px);
bannerText.setText(getString(R.string.pause_notifications_banner, UiUtils.formatRelativeTimestampAsMinutesAgo(getActivity(), Instant.ofEpochMilli(pauseTime), false)));
bannerButton.setText(R.string.resume_notifications_now);
bannerButton.setOnClickListener(v->resumePausedNotifications());
}else{
bannerAdapter.setVisible(false);
}
}
}

View File

@@ -0,0 +1,238 @@
package org.joinmastodon.android.fragments.settings;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.requests.instance.GetInstanceExtendedDescription;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.joinmastodon.android.ui.views.FixedAspectRatioImageView;
import org.joinmastodon.android.utils.ViewImageLoaderHolderTarget;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.fragments.LoaderFragment;
import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class SettingsServerAboutFragment extends LoaderFragment{
private String accountID;
private Instance instance;
private WebView webView;
private LinearLayout scrollingLayout;
public ScrollView scroller;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
instance=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.get(accountID).domain);
loadData();
}
@SuppressLint("SetJavaScriptEnabled")
@Override
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
webView=new WebView(getActivity());
webView.getSettings().setJavaScriptEnabled(true);
webView.setWebViewClient(new WebViewClient(){
@Override
public void onPageFinished(WebView view, String url){
dataLoaded();
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url){
Uri uri=Uri.parse(url);
if(uri.getScheme().equals("http") || uri.getScheme().equals("https")){
UiUtils.launchWebBrowser(getActivity(), url);
}else{
Intent intent=new Intent(Intent.ACTION_VIEW, uri);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try{
startActivity(intent);
}catch(ActivityNotFoundException x){
Toast.makeText(getActivity(), R.string.no_app_to_handle_action, Toast.LENGTH_SHORT).show();
}
}
return true;
}
});
scrollingLayout=new LinearLayout(getActivity());
scrollingLayout.setOrientation(LinearLayout.VERTICAL);
scroller=new ScrollView(getActivity());
scroller.setNestedScrollingEnabled(true);
scroller.setClipToPadding(false);
scroller.addView(scrollingLayout);
FixedAspectRatioImageView banner=new FixedAspectRatioImageView(getActivity());
banner.setAspectRatio(1.914893617f);
banner.setScaleType(ImageView.ScaleType.CENTER_CROP);
banner.setOutlineProvider(OutlineProviders.bottomRoundedRect(16));
banner.setClipToOutline(true);
ViewImageLoader.loadWithoutAnimation(banner, getResources().getDrawable(R.drawable.image_placeholder, getActivity().getTheme()), new UrlImageLoaderRequest(instance.thumbnail));
LinearLayout.LayoutParams blp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
blp.bottomMargin=V.dp(24);
scrollingLayout.addView(banner, blp);
boolean needDivider=false;
if(instance.contactAccount!=null){
needDivider=true;
TextView heading=new TextView(getActivity());
heading.setTextAppearance(R.style.m3_title_small);
heading.setTextColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant));
heading.setSingleLine();
heading.setText(R.string.server_administrator);
heading.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams hlp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(20));
hlp.bottomMargin=V.dp(4);
hlp.leftMargin=hlp.rightMargin=V.dp(16);
scrollingLayout.addView(heading, hlp);
AccountViewModel model=new AccountViewModel(instance.contactAccount, accountID);
AccountViewHolder holder=new AccountViewHolder(this, scrollingLayout, null);
holder.bind(model);
holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground));
holder.itemView.setOnClickListener(v->holder.onClick());
scrollingLayout.addView(holder.itemView);
ViewImageLoader.load(new ViewImageLoaderHolderTarget(holder, 0), null, model.avaRequest, false);
for(int i=0;i<model.emojiHelper.getImageCount();i++){
ViewImageLoader.load(new ViewImageLoaderHolderTarget(holder, i+1), null, model.emojiHelper.getImageRequest(i), false);
}
}
if(!TextUtils.isEmpty(instance.email)){
needDivider=true;
SimpleListItemViewHolder holder=new SimpleListItemViewHolder(getActivity(), scrollingLayout);
ListItem<Void> item=new ListItem<>(R.string.send_email_to_server_admin, 0, R.drawable.ic_mail_24px, ()->{});
holder.bind(item);
holder.itemView.setBackground(UiUtils.getThemeDrawable(getActivity(), android.R.attr.selectableItemBackground));
holder.itemView.setOnClickListener(v->openAdminEmail());
scrollingLayout.addView(holder.itemView);
}
if(needDivider){
View divider=new View(getActivity());
divider.setBackgroundColor(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OutlineVariant));
LinearLayout.LayoutParams dlp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(1));
dlp.leftMargin=dlp.rightMargin=V.dp(16);
scrollingLayout.addView(divider, dlp);
}
scrollingLayout.addView(webView, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
return scroller;
}
@Override
protected void doLoadData(){
new GetInstanceExtendedDescription()
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(GetInstanceExtendedDescription.Response result){
MastodonAPIController.runInBackground(()->{
Activity activity=getActivity();
if(activity==null)
return;
String template;
try(BufferedReader reader=new BufferedReader(new InputStreamReader(getActivity().getAssets().open("server_about_template.htm")))){
StringBuilder sb=new StringBuilder();
String line;
while((line=reader.readLine())!=null){
sb.append(line);
sb.append('\n');
}
template=sb.toString();
}catch(IOException x){
throw new RuntimeException(x);
}
HashMap<String, String> templateParams=new HashMap<>();
templateParams.put("content", result.content);
templateParams.put("colorSurface", getThemeColorAsCss(R.attr.colorM3Surface, 1));
templateParams.put("colorOnSurface", getThemeColorAsCss(R.attr.colorM3OnSurface, 1));
templateParams.put("colorPrimary", getThemeColorAsCss(R.attr.colorM3Primary, 1));
templateParams.put("colorPrimaryTransparent", getThemeColorAsCss(R.attr.colorM3Primary, 0.2f));
for(Map.Entry<String, String> param:templateParams.entrySet()){
template=template.replace("{{"+param.getKey()+"}}", param.getValue());
}
final String html=template;
activity.runOnUiThread(()->{
webView.loadDataWithBaseURL(null, html, "text/html; charset=utf-8", null, null);
});
});
}
})
.exec(accountID);
}
@Override
public void onRefresh(){}
private void openAdminEmail(){
Intent intent=new Intent(Intent.ACTION_VIEW, Uri.fromParts("mailto", instance.email, null));
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
try{
startActivity(intent);
}catch(ActivityNotFoundException x){
Toast.makeText(getActivity(), R.string.no_app_to_handle_action, Toast.LENGTH_SHORT).show();
}
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
scroller.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
progress.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom());
insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom());
}else{
scroller.setPadding(0, 0, 0, 0);
}
super.onApplyWindowInsets(insets);
}
private String getThemeColorAsCss(int attr, float alpha){
int color=UiUtils.getThemeColor(getActivity(), attr);
if(alpha==1f){
return String.format(Locale.US, "#%06X", color & 0xFFFFFF);
}else{
int r=(color >> 16) & 0xFF;
int g=(color >> 8) & 0xFF;
int b=color & 0xFF;
return "rgba("+r+","+g+","+b+","+alpha+")";
}
}
}

View File

@@ -0,0 +1,185 @@
package org.joinmastodon.android.fragments.settings;
import android.app.Fragment;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.WindowInsets;
import android.widget.FrameLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.NestedRecyclerScrollView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.fragments.AppKitFragment;
import me.grishka.appkit.utils.V;
public class SettingsServerFragment extends AppKitFragment{
private String accountID;
private Instance instance;
private TabLayout tabBar;
private TabLayoutMediator tabLayoutMediator;
private ViewPager2 pager;
private FrameLayout[] tabViews;
private View contentView;
private WindowInsets childInsets;
private SettingsServerAboutFragment aboutFragment;
private SettingsServerRulesFragment rulesFragment;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
setTitle(AccountSessionManager.get(accountID).domain);
Bundle args=new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
aboutFragment=new SettingsServerAboutFragment();
aboutFragment.setArguments(args);
rulesFragment=new SettingsServerRulesFragment();
rulesFragment.setArguments(args);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState){
View view=inflater.inflate(R.layout.fragment_settings_server, container, false);
TextView realTitle=view.findViewById(R.id.real_title);
realTitle.setText(getTitle());
realTitle.setSelected(true);
pager=view.findViewById(R.id.pager);
pager.setAdapter(new ServerPagerAdapter());
FrameLayout sizeWrapper=new FrameLayout(getActivity()){
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
pager.getLayoutParams().height=MeasureSpec.getSize(heightMeasureSpec)-getPaddingTop()-getPaddingBottom()-V.dp(48);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
};
tabViews=new FrameLayout[2];
for(int i=0;i<tabViews.length;i++){
FrameLayout tabView=new FrameLayout(getActivity());
tabView.setId(switch(i){
case 0 -> R.id.server_about;
case 1 -> R.id.server_rules;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
tabView.setVisibility(View.GONE);
sizeWrapper.addView(tabView); // needed so the fragment manager will have somewhere to restore the tab fragment
tabViews[i]=tabView;
}
tabBar=view.findViewById(R.id.tabbar);
tabBar.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorM3OnSurfaceVariant), UiUtils.getThemeColor(getActivity(), R.attr.colorM3Primary));
tabBar.setTabTextSize(V.dp(16));
tabLayoutMediator=new TabLayoutMediator(tabBar, pager, (tab, position)->tab.setText(switch(position){
case 0 -> R.string.about_server;
case 1 -> R.string.server_rules;
default -> throw new IllegalStateException("Unexpected value: "+position);
}));
tabLayoutMediator.attach();
NestedRecyclerScrollView scrollView=view.findViewById(R.id.scroller);
scrollView.setScrollableChildSupplier(()->switch(pager.getCurrentItem()){
case 0 -> aboutFragment.scroller;
case 1 -> rulesFragment.getList();
default -> throw new IllegalStateException("Unexpected value: "+pager.getCurrentItem());
});
return contentView=view;
}
@Override
protected void onUpdateToolbar(){
super.onUpdateToolbar();
getToolbar().setTitle(null);
}
private Fragment getFragmentForPage(int page){
return switch(page){
case 0 -> aboutFragment;
case 1 -> rulesFragment;
default -> throw new IllegalStateException();
};
}
@Override
public void onApplyWindowInsets(WindowInsets insets){
if(contentView!=null){
if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){
int insetBottom=insets.getSystemWindowInsetBottom();
childInsets=insets.inset(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0);
applyChildWindowInsets();
insets=insets.inset(0, 0, 0, insetBottom);
}
}
super.onApplyWindowInsets(insets);
}
private void applyChildWindowInsets(){
if(aboutFragment!=null && aboutFragment.isAdded() && childInsets!=null){
aboutFragment.onApplyWindowInsets(childInsets);
rulesFragment.onApplyWindowInsets(childInsets);
}
}
private class ServerPagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
@NonNull
@Override
public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
FrameLayout view=tabViews[viewType];
((ViewGroup)view.getParent()).removeView(view);
view.setVisibility(View.VISIBLE);
view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
return new SimpleViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){
Fragment fragment=getFragmentForPage(position);
if(!fragment.isAdded()){
getChildFragmentManager().beginTransaction().add(holder.itemView.getId(), fragment).commit();
holder.itemView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
getChildFragmentManager().executePendingTransactions();
if(fragment.isAdded()){
holder.itemView.getViewTreeObserver().removeOnPreDrawListener(this);
applyChildWindowInsets();
}
return true;
}
});
}
}
@Override
public int getItemCount(){
return 2;
}
@Override
public int getItemViewType(int position){
return position;
}
}
}

View File

@@ -0,0 +1,47 @@
package org.joinmastodon.android.fragments.settings;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.MastodonRecyclerFragment;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.adapters.InstanceRulesAdapter;
import androidx.recyclerview.widget.RecyclerView;
public class SettingsServerRulesFragment extends MastodonRecyclerFragment<Instance.Rule>{
private String accountID;
public SettingsServerRulesFragment(){
super(20);
}
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
accountID=getArguments().getString("account");
Instance instance=AccountSessionManager.getInstance().getInstanceInfo(AccountSessionManager.get(accountID).domain);
onDataLoaded(instance.rules);
setRefreshEnabled(false);
}
@Override
protected void doLoadData(int offset, int count){}
@Override
protected RecyclerView.Adapter<?> getAdapter(){
return new InstanceRulesAdapter(data);
}
@Override
protected View onCreateFooterView(LayoutInflater inflater){
return inflater.inflate(R.layout.load_more_with_end_mark, null);
}
public RecyclerView getList(){
return list;
}
}

View File

@@ -1,79 +1,56 @@
package org.joinmastodon.android.model;
import android.text.TextUtils;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import org.parceler.Parcel;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.regex.Pattern;
@Parcel
public class Filter extends BaseModel{
@RequiredField
public String id;
@RequiredField
public String phrase;
public transient EnumSet<FilterContext> context=EnumSet.noneOf(FilterContext.class);
public String title;
@RequiredField
public EnumSet<FilterContext> context;
public Instant expiresAt;
public boolean irreversible;
public boolean wholeWord;
public FilterAction filterAction;
@SerializedName("context")
private List<FilterContext> _context;
@RequiredField
public List<FilterKeyword> keywords;
private transient Pattern pattern;
@RequiredField
public List<FilterStatus> statuses;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(_context==null)
throw new ObjectValidationException();
for(FilterContext c:_context){
if(c!=null)
context.add(c);
}
for(FilterKeyword keyword:keywords)
keyword.postprocess();
for(FilterStatus status:statuses)
status.postprocess();
}
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();
}
public boolean matches(Status status){
return matches(status.getContentStatus().getStrippedText());
public boolean isActive(){
return expiresAt==null || expiresAt.isAfter(Instant.now());
}
@Override
public String toString(){
return "Filter{"+
"id='"+id+'\''+
", phrase='"+phrase+'\''+
", title='"+title+'\''+
", context="+context+
", expiresAt="+expiresAt+
", irreversible="+irreversible+
", wholeWord="+wholeWord+
", filterAction="+filterAction+
", keywords="+keywords+
", statuses="+statuses+
'}';
}
public enum FilterContext{
@SerializedName("home")
HOME,
@SerializedName("notifications")
NOTIFICATIONS,
@SerializedName("public")
PUBLIC,
@SerializedName("thread")
THREAD
}
}

View File

@@ -0,0 +1,10 @@
package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
public enum FilterAction{
@SerializedName("warn")
WARN,
@SerializedName("hide")
HIDE
}

View File

@@ -0,0 +1,31 @@
package org.joinmastodon.android.model;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.R;
import androidx.annotation.StringRes;
public enum FilterContext{
@SerializedName("home")
HOME,
@SerializedName("notifications")
NOTIFICATIONS,
@SerializedName("public")
PUBLIC,
@SerializedName("thread")
THREAD,
@SerializedName("account")
ACCOUNT;
@StringRes
public int getDisplayNameRes(){
return switch(this){
case HOME -> R.string.filter_context_home_lists;
case NOTIFICATIONS -> R.string.filter_context_notifications;
case PUBLIC -> R.string.filter_context_public_timelines;
case THREAD -> R.string.filter_context_threads_replies;
case ACCOUNT -> R.string.filter_context_profiles;
};
}
}

View File

@@ -0,0 +1,21 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.parceler.Parcel;
@AllFieldsAreRequired
@Parcel
public class FilterKeyword extends BaseModel{
public String id;
public String keyword;
public boolean wholeWord;
@Override
public String toString(){
return "FilterKeyword{"+
"id='"+id+'\''+
", keyword='"+keyword+'\''+
", wholeWord="+wholeWord+
'}';
}
}

View File

@@ -0,0 +1,11 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.AllFieldsAreRequired;
import org.parceler.Parcel;
@AllFieldsAreRequired
@Parcel
public class FilterStatus extends BaseModel{
public String id;
public String statusId;
}

View File

@@ -0,0 +1,69 @@
package org.joinmastodon.android.model;
import android.text.TextUtils;
import com.google.gson.annotations.SerializedName;
import org.joinmastodon.android.api.ObjectValidationException;
import org.joinmastodon.android.api.RequiredField;
import java.time.Instant;
import java.util.EnumSet;
import java.util.List;
import java.util.regex.Pattern;
public class LegacyFilter extends BaseModel{
@RequiredField
public String id;
@RequiredField
public String phrase;
public transient EnumSet<FilterContext> context=EnumSet.noneOf(FilterContext.class);
public Instant expiresAt;
public boolean irreversible;
public boolean wholeWord;
@SerializedName("context")
private List<FilterContext> _context;
private transient Pattern pattern;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(_context==null)
throw new ObjectValidationException();
for(FilterContext c:_context){
if(c!=null)
context.add(c);
}
}
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();
}
public boolean matches(Status status){
return matches(status.getContentStatus().getStrippedText());
}
@Override
public String toString(){
return "Filter{"+
"id='"+id+'\''+
", phrase='"+phrase+'\''+
", context="+context+
", expiresAt="+expiresAt+
", irreversible="+irreversible+
", wholeWord="+wholeWord+
'}';
}
}

View File

@@ -1,33 +0,0 @@
package org.joinmastodon.android.model;
import android.text.SpannableStringBuilder;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import java.util.Collections;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class ParsedAccount{
public Account account;
public CharSequence parsedName, parsedBio;
public CustomEmojiHelper emojiHelper;
public ImageLoaderRequest avatarRequest;
public ParsedAccount(Account account, String accountID){
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
emojiHelper=new CustomEmojiHelper();
SpannableStringBuilder ssb=new SpannableStringBuilder(parsedName);
ssb.append(parsedBio);
emojiHelper.setText(ssb);
avatarRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(40), V.dp(40));
}
}

View File

@@ -13,7 +13,7 @@ public class Poll extends BaseModel{
@RequiredField
public String id;
public Instant expiresAt;
private boolean expired;
public boolean expired;
public boolean multiple;
public int votersCount;
public int votesCount;

View File

@@ -1,11 +1,14 @@
package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AccountField;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import java.util.Collections;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
@@ -14,14 +17,19 @@ public class AccountViewModel{
public final Account account;
public final ImageLoaderRequest avaRequest;
public final CustomEmojiHelper emojiHelper;
public final CharSequence parsedName;
public final CharSequence parsedName, parsedBio;
public final String verifiedLink;
public AccountViewModel(Account account){
public AccountViewModel(Account account, String accountID){
this.account=account;
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50));
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis));
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
else
parsedName=account.displayName;
parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID);
emojiHelper.setText(parsedName);
String verifiedLink=null;
for(AccountField fld:account.fields){
if(fld.verifiedAt!=null){

View File

@@ -0,0 +1,74 @@
package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.R;
import java.util.function.Consumer;
public class CheckableListItem<T> extends ListItem<T>{
public Style style;
public boolean checked;
public Consumer<Boolean> checkedChangeListener;
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick, T parentObject, boolean dividerAfter){
super(title, subtitle, iconRes, onClick, parentObject, 0, dividerAfter);
this.style=style;
this.checked=checked;
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Runnable onClick){
this(title, subtitle, style, checked, 0, onClick, null, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, Runnable onClick, T parentObject){
this(title, subtitle, style, checked, 0, onClick, parentObject, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick){
this(title, subtitle, style, checked, iconRes, onClick, null, false);
}
public CheckableListItem(String title, String subtitle, Style style, boolean checked, int iconRes, Runnable onClick, T parentObject){
this(title, subtitle, style, checked, iconRes, onClick, parentObject, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Runnable onClick){
this(titleRes, subtitleRes, style, checked, 0, onClick, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, Runnable onClick, boolean dividerAfter){
this(titleRes, subtitleRes, style, checked, 0, onClick, dividerAfter);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Runnable onClick){
this(titleRes, subtitleRes, style, checked, iconRes, onClick, false);
}
public CheckableListItem(int titleRes, int subtitleRes, Style style, boolean checked, int iconRes, Runnable onClick, boolean dividerAfter){
super(titleRes, subtitleRes, iconRes, onClick, 0, dividerAfter);
this.style=style;
this.checked=checked;
}
@Override
public int getItemViewType(){
return switch(style){
case CHECKBOX -> R.id.list_item_checkbox;
case RADIO -> R.id.list_item_radio;
case SWITCH -> R.id.list_item_switch;
};
}
public void setChecked(boolean checked){
this.checked=checked;
}
public void toggle(){
checked=!checked;
}
public enum Style{
CHECKBOX,
RADIO,
SWITCH
}
}

View File

@@ -0,0 +1,78 @@
package org.joinmastodon.android.model.viewmodel;
import org.joinmastodon.android.R;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
public class ListItem<T>{
public String title;
public String subtitle;
@StringRes
public int titleRes;
@StringRes
public int subtitleRes;
@DrawableRes
public int iconRes;
public int colorOverrideAttr;
public boolean dividerAfter;
public Runnable onClick;
public boolean isEnabled=true;
public T parentObject;
public ListItem(String title, String subtitle, int iconRes, Runnable onClick, T parentObject, int colorOverrideAttr, boolean dividerAfter){
this.title=title;
this.subtitle=subtitle;
this.iconRes=iconRes;
this.colorOverrideAttr=colorOverrideAttr;
this.dividerAfter=dividerAfter;
this.onClick=onClick;
this.parentObject=parentObject;
if(onClick==null)
isEnabled=false;
}
public ListItem(String title, String subtitle, Runnable onClick){
this(title, subtitle, 0, onClick, null, 0, false);
}
public ListItem(String title, String subtitle, Runnable onClick, T parentObject){
this(title, subtitle, 0, onClick, parentObject, 0, false);
}
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Runnable onClick){
this(title, subtitle, iconRes, onClick, null, 0, false);
}
public ListItem(String title, String subtitle, @DrawableRes int iconRes, Runnable onClick, T parentObject){
this(title, subtitle, iconRes, onClick, parentObject, 0, false);
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Runnable onClick){
this(null, null, 0, onClick, null, 0, false);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, Runnable onClick, int colorOverrideAttr, boolean dividerAfter){
this(null, null, 0, onClick, null, colorOverrideAttr, dividerAfter);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Runnable onClick){
this(null, null, iconRes, onClick, null, 0, false);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public ListItem(@StringRes int titleRes, @StringRes int subtitleRes, @DrawableRes int iconRes, Runnable onClick, int colorOverrideAttr, boolean dividerAfter){
this(null, null, iconRes, onClick, null, colorOverrideAttr, dividerAfter);
this.titleRes=titleRes;
this.subtitleRes=subtitleRes;
}
public int getItemViewType(){
return colorOverrideAttr==0 ? R.id.list_item_simple : R.id.list_item_simple_tinted;
}
}

View File

@@ -111,21 +111,12 @@ public class AccountSwitcherSheet extends BottomSheet{
}
private void logOut(String accountID){
AccountSession session=AccountSessionManager.getInstance().getAccount(accountID);
new RevokeOauthToken(session.app.clientId, session.app.clientSecret, session.token.accessToken)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Object result){
onLoggedOut(accountID);
}
@Override
public void onError(ErrorResponse error){
onLoggedOut(accountID);
}
})
.wrapProgress(activity, R.string.loading, false)
.exec(accountID);
AccountSessionManager.get(accountID).logOut(activity, ()->{
dismiss();
activity.finish();
Intent intent=new Intent(activity, MainActivity.class);
activity.startActivity(intent);
});
}
private void logOutAll(){
@@ -163,11 +154,6 @@ public class AccountSwitcherSheet extends BottomSheet{
}
}
private void onLoggedOut(String accountID){
AccountSessionManager.getInstance().removeAccount(accountID);
dismiss();
}
@Override
protected void onWindowInsetsUpdated(WindowInsets insets){
if(Build.VERSION.SDK_INT>=29){

View File

@@ -2,12 +2,21 @@ package org.joinmastodon.android.ui;
import android.app.AlertDialog;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import org.joinmastodon.android.R;
import androidx.annotation.StringRes;
import me.grishka.appkit.utils.V;
public class M3AlertDialogBuilder extends AlertDialog.Builder{
private CharSequence supportingText, title, helpText;
private AlertDialog alert;
public M3AlertDialogBuilder(Context context){
super(context);
}
@@ -18,12 +27,36 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
@Override
public AlertDialog create(){
AlertDialog alert=super.create();
if(!TextUtils.isEmpty(helpText) && !TextUtils.isEmpty(supportingText))
throw new IllegalStateException("You can't have both help text and supporting text in the same alert");
if(!TextUtils.isEmpty(supportingText)){
View titleLayout=getContext().getSystemService(LayoutInflater.class).inflate(R.layout.alert_title_with_supporting_text, null);
TextView title=titleLayout.findViewById(R.id.title);
TextView subtitle=titleLayout.findViewById(R.id.subtitle);
title.setText(this.title);
subtitle.setText(supportingText);
setCustomTitle(titleLayout);
}else if(!TextUtils.isEmpty(helpText)){
View titleLayout=getContext().getSystemService(LayoutInflater.class).inflate(R.layout.alert_title_with_help, null);
TextView title=titleLayout.findViewById(R.id.title);
TextView helpText=titleLayout.findViewById(R.id.help_text);
View helpButton=titleLayout.findViewById(R.id.help);
title.setText(this.title);
helpText.setText(this.helpText);
helpButton.setOnClickListener(v->{
helpText.setVisibility(helpText.getVisibility()==View.VISIBLE ? View.GONE : View.VISIBLE);
helpButton.setSelected(helpText.getVisibility()==View.VISIBLE);
});
setCustomTitle(titleLayout);
}
alert=super.create();
alert.create();
Button btn=alert.getButton(AlertDialog.BUTTON_POSITIVE);
if(btn!=null){
View buttonBar=(View) btn.getParent();
buttonBar.setPadding(V.dp(16), 0, V.dp(16), V.dp(24));
buttonBar.setPadding(V.dp(16), V.dp(16), V.dp(16), V.dp(16));
((View)buttonBar.getParent()).setPadding(0, 0, 0, 0);
}
// hacc
@@ -49,13 +82,40 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
scrollView.setPadding(0, 0, 0, 0);
}
}
int messageID=getContext().getResources().getIdentifier("message", "id", "android");
if(messageID!=0){
View message=alert.findViewById(messageID);
if(message!=null){
message.setPadding(message.getPaddingLeft(), message.getPaddingTop(), message.getPaddingRight(), V.dp(24));
}
}
return alert;
}
public M3AlertDialogBuilder setSupportingText(CharSequence text){
supportingText=text;
return this;
}
public M3AlertDialogBuilder setSupportingText(@StringRes int text){
supportingText=getContext().getString(text);
return this;
}
@Override
public M3AlertDialogBuilder setTitle(CharSequence title){
super.setTitle(title);
this.title=title;
return this;
}
@Override
public M3AlertDialogBuilder setTitle(@StringRes int title){
super.setTitle(title);
this.title=getContext().getString(title);
return this;
}
public M3AlertDialogBuilder setHelpText(CharSequence text){
helpText=text;
return this;
}
public M3AlertDialogBuilder setHelpText(@StringRes int text){
helpText=getContext().getString(text);
return this;
}
}

View File

@@ -10,6 +10,7 @@ import me.grishka.appkit.utils.V;
public class OutlineProviders{
private static final SparseArray<ViewOutlineProvider> roundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> topRoundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> bottomRoundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> endRoundedRects=new SparseArray<>();
public static final int RADIUS_XSMALL=4;
@@ -54,6 +55,15 @@ public class OutlineProviders{
return provider;
}
public static ViewOutlineProvider bottomRoundedRect(int dp){
ViewOutlineProvider provider=bottomRoundedRects.get(dp);
if(provider!=null)
return provider;
provider=new BottomRoundRectOutlineProvider(V.dp(dp));
bottomRoundedRects.put(dp, provider);
return provider;
}
public static ViewOutlineProvider endRoundedRect(int dp){
ViewOutlineProvider provider=endRoundedRects.get(dp);
if(provider!=null)
@@ -89,6 +99,19 @@ public class OutlineProviders{
}
}
private static class BottomRoundRectOutlineProvider extends ViewOutlineProvider{
private final int radius;
private BottomRoundRectOutlineProvider(int radius){
this.radius=radius;
}
@Override
public void getOutline(View view, Outline outline){
outline.setRoundRect(0, -radius, view.getWidth(), view.getHeight(), radius);
}
}
private static class EndRoundRectOutlineProvider extends ViewOutlineProvider{
private final int radius;

View File

@@ -0,0 +1,54 @@
package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.viewholders.CheckboxOrRadioListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SwitchListItemViewHolder;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemViewHolder<?>>{
private List<ListItem<T>> items;
public GenericListItemsAdapter(List<ListItem<T>> items){
this.items=items;
}
@NonNull
@Override
public ListItemViewHolder<?> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
if(viewType==R.id.list_item_simple || viewType==R.id.list_item_simple_tinted)
return new SimpleListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_switch)
return new SwitchListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_checkbox)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, false);
if(viewType==R.id.list_item_radio)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, true);
throw new IllegalArgumentException("Unexpected view type "+viewType);
}
@SuppressWarnings("unchecked")
@Override
public void onBindViewHolder(@NonNull ListItemViewHolder<?> holder, int position){
((ListItemViewHolder<ListItem<T>>)holder).bind(items.get(position));
}
@Override
public int getItemCount(){
return items.size();
}
@Override
public int getItemViewType(int position){
return items.get(position).getItemViewType();
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.viewholders.InstanceRuleViewHolder;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class InstanceRulesAdapter extends RecyclerView.Adapter<InstanceRuleViewHolder>{
private final List<Instance.Rule> rules;
public InstanceRulesAdapter(List<Instance.Rule> rules){
this.rules=rules;
}
@NonNull
@Override
public InstanceRuleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new InstanceRuleViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull InstanceRuleViewHolder holder, int position){
holder.setPosition(position);
holder.bind(rules.get(position));
}
@Override
public int getItemCount(){
return rules.size();
}
}

View File

@@ -9,6 +9,7 @@ import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.OutlineProviders;
@@ -29,7 +30,10 @@ public class AccountStatusDisplayItem extends StatusDisplayItem{
public AccountStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Account account){
super(parentID, parentFragment);
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
if(AccountSessionManager.get(parentFragment.getAccountID()).getLocalPreferences().customEmojiInNames)
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
else
parsedName=account.displayName;
emojiHelper.setText(parsedName);
if(!TextUtils.isEmpty(account.avatar))
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));

View File

@@ -103,7 +103,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
@Override
public void onBind(AudioStatusDisplayItem item){
int seconds=(int)item.attachment.getDuration();
String duration=UiUtils.formatDuration(seconds);
String duration=UiUtils.formatMediaDuration(seconds);
AudioPlayerService service=AudioPlayerService.getInstance();
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
forwardBtn.setVisibility(View.VISIBLE);
@@ -168,7 +168,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
setPlayButtonPlaying(false, true);
forwardBtn.setVisibility(View.INVISIBLE);
rewindBtn.setVisibility(View.INVISIBLE);
time.setText(UiUtils.formatDuration((int)item.attachment.getDuration()));
time.setText(UiUtils.formatMediaDuration((int)item.attachment.getDuration()));
}
}
@@ -187,7 +187,7 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
int posSeconds=(int)pos;
if(posSeconds!=lastPosSeconds){
lastPosSeconds=posSeconds;
time.setText(UiUtils.formatDuration(posSeconds)+"/"+UiUtils.formatDuration((int)item.attachment.getDuration()));
time.setText(UiUtils.formatMediaDuration(posSeconds)+"/"+UiUtils.formatMediaDuration((int)item.attachment.getDuration()));
}
}

View File

@@ -68,7 +68,7 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
reblogs.setText(itemView.getResources().getQuantityString(R.plurals.x_reblogs, (int)item.status.reblogsCount, item.status.reblogsCount));
if(s.editedAt!=null){
editHistory.setVisibility(View.VISIBLE);
editHistory.setText(item.parentFragment.getString(R.string.last_edit_at_x, UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt)));
editHistory.setText(item.parentFragment.getString(R.string.last_edit_at_x, UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt, false)));
}else{
editHistory.setVisibility(View.GONE);
}

View File

@@ -6,13 +6,16 @@ import android.content.res.ColorStateList;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
@@ -133,6 +136,20 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
private void onBoostClick(View v){
if(GlobalUserPreferences.confirmBoost){
PopupMenu menu=new PopupMenu(itemView.getContext(), boost);
menu.getMenu().add(R.string.button_reblog);
menu.setOnMenuItemClickListener(item->{
doBoost();
return true;
});
menu.show();
}else{
doBoost();
}
}
private void doBoost(){
AccountSessionManager.getInstance().getAccount(item.accountID).getStatusInteractionController().setReblogged(item.status, !item.status.reblogged);
boost.setSelected(item.status.reblogged);
bindButton(boost, item.status.reblogsCount);

View File

@@ -71,7 +71,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
this.accountID=accountID;
parsedName=new SpannableStringBuilder(user.displayName);
this.status=status;
HtmlParser.parseCustomEmoji(parsedName, user.emojis);
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(parsedName, user.emojis);
emojiHelper.setText(parsedName);
if(status!=null){
hasVisibilityToggle=status.sensitive || !TextUtils.isEmpty(status.spoilerText);

View File

@@ -6,7 +6,9 @@ import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
@@ -90,6 +92,7 @@ public abstract class StatusDisplayItem{
ArrayList<StatusDisplayItem> items=new ArrayList<>();
Status statusForContent=status.getContentStatus();
HeaderStatusDisplayItem header=null;
boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts;
if((flags & FLAG_NO_HEADER)==0){
if(status.reblog!=null){
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, fragment.getString(R.string.user_boosted, status.account.displayName), status.account.emojis, R.drawable.ic_repeat_20px));
@@ -104,7 +107,7 @@ public abstract class StatusDisplayItem{
}
ArrayList<StatusDisplayItem> contentItems;
if(!TextUtils.isEmpty(statusForContent.spoilerText)){
if(!TextUtils.isEmpty(statusForContent.spoilerText) && AccountSessionManager.get(accountID).getLocalPreferences().showCWs){
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, statusForContent);
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
@@ -126,6 +129,8 @@ public abstract class StatusDisplayItem{
MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent);
if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0)
mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden);
else if(statusForContent.sensitive && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia)
mediaGrid.sensitiveRevealed=true;
contentItems.add(mediaGrid);
}
for(Attachment att:statusForContent.mediaAttachments){
@@ -140,7 +145,9 @@ public abstract class StatusDisplayItem{
contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent));
}
if((flags & FLAG_NO_FOOTER)==0){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
FooterStatusDisplayItem footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
footer.hideCounts=hideCounts;
items.add(footer);
if(status.hasGapAfter && !(fragment instanceof ThreadFragment))
items.add(new GapStatusDisplayItem(parentID, fragment));
}

View File

@@ -60,7 +60,7 @@ public class MediaAttachmentViewController{
altButton.setVisibility(TextUtils.isEmpty(attachment.description) ? View.GONE : View.VISIBLE);
}
if(type==MediaGridStatusDisplayItem.GridItemType.VIDEO){
duration.setText(UiUtils.formatDuration((int)attachment.getDuration()));
duration.setText(UiUtils.formatMediaDuration((int)attachment.getDuration()));
}
didClear=false;
}

View File

@@ -71,6 +71,7 @@ import org.parceler.Parcels;
import java.io.File;
import java.lang.reflect.Method;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
@@ -99,6 +100,7 @@ import okhttp3.MediaType;
public class UiUtils{
private static Handler mainHandler=new Handler(Looper.getMainLooper());
private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR=DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT=DateTimeFormatter.ofPattern("d MMM");
private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
public static final DateTimeFormatter DATE_TIME_FORMATTER=DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
private UiUtils(){}
@@ -144,21 +146,52 @@ public class UiUtils{
}
}
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant){
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant, boolean relativeHours){
long t=instant.toEpochMilli();
long now=System.currentTimeMillis();
long diff=now-t;
if(diff<1000L){
long diff=System.currentTimeMillis()-t;
if(diff<1000L && diff>-1000L){
return context.getString(R.string.time_just_now);
}else if(diff<60_000L){
int secs=(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
}else if(diff<3600_000L){
int mins=(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
}else if(diff>0){
if(diff<60_000L){
int secs=(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
}else if(diff<3600_000L){
int mins=(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
}else if(relativeHours && diff<24*3600_000L){
int hours=(int)(diff/3600_000L);
return context.getResources().getQuantityString(R.plurals.x_hours_ago, hours, hours);
}
}else{
return DATE_TIME_FORMATTER.format(instant.atZone(ZoneId.systemDefault()));
if(diff>-60_000L){
int secs=-(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.in_x_seconds, secs, secs);
}else if(diff>-3600_000L){
int mins=-(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.in_x_minutes, mins, mins);
}else if(relativeHours && diff>-24*3600_000L){
int hours=-(int)(diff/3600_000L);
return context.getResources().getQuantityString(R.plurals.in_x_hours, hours, hours);
}
}
ZonedDateTime dt=instant.atZone(ZoneId.systemDefault());
ZonedDateTime now=ZonedDateTime.now();
String formattedTime=TIME_FORMATTER.format(dt);
String formattedDate;
LocalDate today=now.toLocalDate();
LocalDate date=dt.toLocalDate();
if(date.equals(today)){
formattedDate=context.getString(R.string.today);
}else if(date.equals(today.minusDays(1))){
formattedDate=context.getString(R.string.yesterday);
}else if(date.equals(today.plusDays(1))){
formattedDate=context.getString(R.string.tomorrow);
}else if(date.getYear()==today.getYear()){
formattedDate=DATE_FORMATTER_SHORT.format(dt);
}else{
formattedDate=DATE_FORMATTER_SHORT_WITH_YEAR.format(dt);
}
return context.getString(R.string.date_at_time, formattedDate, formattedTime);
}
public static String formatTimeLeft(Context context, Instant instant){
@@ -317,7 +350,7 @@ public class UiUtils{
}
public static void showConfirmationAlert(Context context, @StringRes int title, @StringRes int message, @StringRes int confirmButton, Runnable onConfirmed){
showConfirmationAlert(context, context.getString(title), context.getString(message), context.getString(confirmButton), onConfirmed);
showConfirmationAlert(context, context.getString(title), message==0 ? null : context.getString(message), context.getString(confirmButton), onConfirmed);
}
public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, Runnable onConfirmed){
@@ -399,24 +432,26 @@ public class UiUtils{
}
public static void confirmDeletePost(Activity activity, String accountID, Status status, Consumer<Status> resultCallback){
showConfirmationAlert(activity, R.string.confirm_delete_title, R.string.confirm_delete, R.string.delete, ()->{
new DeleteStatus(status.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
resultCallback.accept(result);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id);
E.post(new StatusDeletedEvent(status.id, accountID));
}
Runnable delete=()->new DeleteStatus(status.id)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Status result){
resultCallback.accept(result);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().deleteStatus(status.id);
E.post(new StatusDeletedEvent(status.id, accountID));
}
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
}
})
.wrapProgress(activity, R.string.deleting, false)
.exec(accountID);
});
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
}
})
.wrapProgress(activity, R.string.deleting, false)
.exec(accountID);
if(GlobalUserPreferences.confirmDeletePost)
showConfirmationAlert(activity, R.string.confirm_delete_title, R.string.confirm_delete, R.string.delete, delete);
else
delete.run();
}
public static void setRelationshipToActionButton(Relationship relationship, Button button){
@@ -488,25 +523,32 @@ public class UiUtils{
}else if(relationship.muting){
confirmToggleMuteUser(activity, accountID, account, true, resultCallback);
}else{
progressCallback.accept(true);
new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
resultCallback.accept(result);
progressCallback.accept(false);
if(!result.following && !result.requested){
E.post(new RemoveAccountPostsEvent(accountID, account.id, true));
Runnable action=()->{
progressCallback.accept(true);
new SetAccountFollowed(account.id, !relationship.following && !relationship.requested, true)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
resultCallback.accept(result);
progressCallback.accept(false);
if(!result.following && !result.requested){
E.post(new RemoveAccountPostsEvent(accountID, account.id, true));
}
}
}
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
progressCallback.accept(false);
}
})
.exec(accountID);
@Override
public void onError(ErrorResponse error){
error.showToast(activity);
progressCallback.accept(false);
}
})
.exec(accountID);
};
if(relationship.following && GlobalUserPreferences.confirmUnfollow){
showConfirmationAlert(activity, null, activity.getString(R.string.unfollow_confirmation, account.getDisplayUsername()), activity.getString(R.string.unfollow), action);
}else{
action.run();
}
}
}
@@ -586,9 +628,9 @@ public class UiUtils{
public static void setUserPreferredTheme(Context context){
context.setTheme(switch(GlobalUserPreferences.theme){
case AUTO -> GlobalUserPreferences.trueBlackTheme ? R.style.Theme_Mastodon_AutoLightDark_TrueBlack : R.style.Theme_Mastodon_AutoLightDark;
case AUTO -> R.style.Theme_Mastodon_AutoLightDark;
case LIGHT -> R.style.Theme_Mastodon_Light;
case DARK -> GlobalUserPreferences.trueBlackTheme ? R.style.Theme_Mastodon_Dark_TrueBlack : R.style.Theme_Mastodon_Dark;
case DARK -> R.style.Theme_Mastodon_Dark;
});
}
@@ -718,7 +760,7 @@ public class UiUtils{
}
@SuppressLint("DefaultLocale")
public static String formatDuration(int seconds){
public static String formatMediaDuration(int seconds){
if(seconds>=3600)
return String.format("%d:%02d:%02d", seconds/3600, seconds%3600/60, seconds%60);
else
@@ -750,4 +792,20 @@ public class UiUtils{
}
return insets;
}
public static String formatDuration(Context context, int seconds){
if(seconds<3600){
int minutes=seconds/60;
return context.getResources().getQuantityString(R.plurals.x_minutes, minutes, minutes);
}else if(seconds<24*3600){
int hours=seconds/3600;
return context.getResources().getQuantityString(R.plurals.x_hours, hours, hours);
}else if(seconds>=7*24*3600 && seconds%(7*24*3600)<24*3600){
int weeks=seconds/(7*24*3600);
return context.getResources().getQuantityString(R.plurals.x_weeks, weeks, weeks);
}else{
int days=seconds/(24*3600);
return context.getResources().getQuantityString(R.plurals.x_days, days, days);
}
}
}

View File

@@ -19,6 +19,7 @@ import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
@@ -58,7 +59,7 @@ public class ComposeAutocompleteViewController{
private FrameLayout contentView;
private UsableRecyclerView list;
private ListImageLoaderWrapper imgLoader;
private List<WrappedAccount> users=Collections.emptyList();
private List<AccountViewModel> users=Collections.emptyList();
private List<Hashtag> hashtags=Collections.emptyList();
private List<WrappedEmoji> emojis=Collections.emptyList();
private Mode mode;
@@ -226,8 +227,8 @@ public class ComposeAutocompleteViewController{
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
List<WrappedAccount> oldList=users;
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
List<AccountViewModel> oldList=users;
users=result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList());
if(isLoading){
isLoading=false;
if(users.size()>=LOADING_FAKE_USER_COUNT){
@@ -313,7 +314,7 @@ public class ComposeAutocompleteViewController{
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
WrappedAccount a=users.get(position);
AccountViewModel a=users.get(position);
if(image==0)
return a.avaRequest;
return a.emojiHelper.getImageRequest(image-1);
@@ -325,7 +326,7 @@ public class ComposeAutocompleteViewController{
}
}
private class UserViewHolder extends BindableViewHolder<WrappedAccount> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private class UserViewHolder extends BindableViewHolder<AccountViewModel> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
protected final ImageView ava;
protected final TextView username;
@@ -338,7 +339,7 @@ public class ComposeAutocompleteViewController{
}
@Override
public void onBind(WrappedAccount item){
public void onBind(AccountViewModel item){
username.setText("@"+item.account.acct);
}
@@ -483,21 +484,6 @@ public class ComposeAutocompleteViewController{
}
}
private static class WrappedAccount{
private Account account;
private CharSequence parsedName;
private CustomEmojiHelper emojiHelper;
private ImageLoaderRequest avaRequest;
public WrappedAccount(Account account){
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName);
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
}
}
private static class WrappedEmoji{
private Emoji emoji;
private ImageLoaderRequest request;

View File

@@ -89,10 +89,32 @@ public class ComposeLanguageAlertViewController{
}
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))){
if(previouslySelected.index!=-1 && ((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{
int i=0;
boolean found=false;
for(SpecialLocaleInfo li:specialLocales){
if(li.locale.equals(previouslySelected.locale)){
selectedLocale=li.locale;
selectedIndex=i;
found=true;
break;
}
i++;
}
if(!found){
for(LocaleInfo li:allLocales){
if(li.locale.equals(previouslySelected.locale)){
selectedLocale=li.locale;
selectedIndex=i;
break;
}
i++;
}
}
}
}else{
selectedLocale=specialLocales.get(0).locale;

View File

@@ -543,6 +543,23 @@ public class ComposeMediaViewController{
return attachments.size()<MAX_ATTACHMENTS;
}
public int getMissingAltTextAttachmentCount(){
int count=0;
for(DraftMediaAttachment att:attachments){
if(TextUtils.isEmpty(att.description))
count++;
}
return count;
}
public boolean areAllAttachmentsImages(){
for(DraftMediaAttachment att:attachments){
if(!att.mimeType.startsWith("image/"))
return false;
}
return true;
}
public int getMaxAttachments(){
return MAX_ATTACHMENTS;
}

View File

@@ -108,7 +108,7 @@ public class ComposePollViewController{
updatePollOptionHints();
pollDuration=savedInstanceState.getInt("pollDuration");
pollIsMultipleChoice=savedInstanceState.getBoolean("pollMultiple");
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
}else if(savedInstanceState!=null && !pollOptions.isEmpty()){ // Fragment was recreated but instance was retained
pollWrap.setVisibility(View.VISIBLE);
@@ -119,7 +119,7 @@ public class ComposePollViewController{
opt.edit.setText(oldOpt.edit.getText());
}
updatePollOptionHints();
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
}else if(savedInstanceState==null && fragment.editingStatus!=null && fragment.editingStatus.poll!=null){
pollWrap.setVisibility(View.VISIBLE);
@@ -129,11 +129,11 @@ public class ComposePollViewController{
}
pollDuration=(int)fragment.editingStatus.poll.expiresAt.minus(fragment.editingStatus.createdAt.toEpochMilli(), ChronoUnit.MILLIS).getEpochSecond();
updatePollOptionHints();
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollIsMultipleChoice=fragment.editingStatus.poll.multiple;
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
}else{
pollDurationValue.setText(formatPollDuration(24*3600));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), 24*3600));
pollStyleValue.setText(R.string.compose_poll_single_choice);
}
}
@@ -186,7 +186,7 @@ public class ComposePollViewController{
int selectedOption=-1;
for(int i=0;i<POLL_LENGTH_OPTIONS.length;i++){
int l=POLL_LENGTH_OPTIONS[i];
options[i]=formatPollDuration(l);
options[i]=UiUtils.formatDuration(fragment.getContext(), l);
if(l==pollDuration)
selectedOption=i;
}
@@ -196,25 +196,12 @@ public class ComposePollViewController{
.setTitle(R.string.poll_length)
.setPositiveButton(R.string.ok, (dialog, which)->{
pollDuration=POLL_LENGTH_OPTIONS[chosenOption[0]];
pollDurationValue.setText(formatPollDuration(pollDuration));
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
})
.setNegativeButton(R.string.cancel, null)
.show();
}
private String formatPollDuration(int seconds){
if(seconds<3600){
int minutes=seconds/60;
return fragment.getResources().getQuantityString(R.plurals.x_minutes, minutes, minutes);
}else if(seconds<24*3600){
int hours=seconds/3600;
return fragment.getResources().getQuantityString(R.plurals.x_hours, hours, hours);
}else{
int days=seconds/(24*3600);
return fragment.getResources().getQuantityString(R.plurals.x_days, days, days);
}
}
private void showPollStyleAlert(){
final int[] option={pollIsMultipleChoice ? R.id.multiple_choice : R.id.single_choice};
AlertDialog alert=new M3AlertDialogBuilder(fragment.getActivity())

View File

@@ -110,6 +110,8 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
}
public void bindRelationship(){
if(relationships==null)
return;
Relationship rel=relationships.get(item.account.id);
if(rel==null || AccountSessionManager.getInstance().isSelf(accountID, item.account)){
button.setVisibility(View.GONE);
@@ -193,6 +195,8 @@ public class AccountViewHolder extends BindableViewHolder<AccountViewModel> impl
}
private void onButtonClick(View v){
if(relationships==null)
return;
ProgressDialog progress=new ProgressDialog(fragment.getActivity());
progress.setMessage(fragment.getString(R.string.loading));
progress.setCancelable(false);

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.views.CheckableLinearLayout;
public abstract class CheckableListItemViewHolder extends ListItemViewHolder<CheckableListItem<?>>{
protected final CheckableLinearLayout checkableLayout;
public CheckableListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list_checkable, parent);
checkableLayout=(CheckableLinearLayout) itemView;
}
@Override
public void onBind(CheckableListItem<?> item){
super.onBind(item);
checkableLayout.setChecked(item.checked);
}
}

View File

@@ -0,0 +1,25 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import me.grishka.appkit.utils.V;
public class CheckboxOrRadioListItemViewHolder extends CheckableListItemViewHolder{
public CheckboxOrRadioListItemViewHolder(Context context, ViewGroup parent, boolean radio){
super(context, parent);
View iconView=new View(context);
iconView.setDuplicateParentStateEnabled(true);
CompoundButton terribleHack=radio ? new RadioButton(context) : new CheckBox(context);
iconView.setBackground(terribleHack.getButtonDrawable());
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(32), V.dp(32));
lp.setMarginStart(V.dp(12));
lp.setMarginEnd(V.dp(4));
checkableLayout.addView(iconView, lp);
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.viewholders;
import android.annotation.SuppressLint;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.text.HtmlParser;
import me.grishka.appkit.utils.BindableViewHolder;
public class InstanceRuleViewHolder extends BindableViewHolder<Instance.Rule>{
private final TextView text, number;
private int position;
public InstanceRuleViewHolder(ViewGroup parent){
super(parent.getContext(), R.layout.item_server_rule, parent);
text=findViewById(R.id.text);
number=findViewById(R.id.number);
}
public void setPosition(int position){
this.position=position;
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(Instance.Rule item){
if(item.parsedText==null){
item.parsedText=HtmlParser.parseLinks(item.text);
}
text.setText(item.parsedText);
number.setText(String.format("%d", position+1));
}
}

View File

@@ -0,0 +1,80 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.content.res.ColorStateList;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class ListItemViewHolder<T extends ListItem<?>> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{
protected final TextView title;
protected final TextView subtitle;
protected final ImageView icon;
protected final LinearLayout view;
public ListItemViewHolder(Context context, int layout, ViewGroup parent){
super(context, layout, parent);
title=findViewById(R.id.title);
subtitle=findViewById(R.id.subtitle);
icon=findViewById(R.id.icon);
view=(LinearLayout) itemView;
}
@Override
public void onBind(T item){
if(TextUtils.isEmpty(item.title))
title.setText(item.titleRes);
else
title.setText(item.title);
if(TextUtils.isEmpty(item.subtitle) && item.subtitleRes==0){
subtitle.setVisibility(View.GONE);
title.setMaxLines(2);
view.setMinimumHeight(V.dp(56));
}else{
subtitle.setVisibility(View.VISIBLE);
title.setMaxLines(1);
view.setMinimumHeight(V.dp(72));
if(TextUtils.isEmpty(item.subtitle))
subtitle.setText(item.subtitleRes);
else
subtitle.setText(item.subtitle);
}
if(item.iconRes!=0){
icon.setVisibility(View.VISIBLE);
icon.setImageResource(item.iconRes);
}else{
icon.setVisibility(View.GONE);
}
if(item.colorOverrideAttr!=0){
int color=UiUtils.getThemeColor(view.getContext(), item.colorOverrideAttr);
title.setTextColor(color);
icon.setImageTintList(ColorStateList.valueOf(color));
}
view.setAlpha(item.isEnabled ? 1 : .4f);
}
@Override
public boolean isEnabled(){
return item.isEnabled;
}
@Override
public void onClick(){
item.onClick.run();
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
public class SimpleListItemViewHolder extends ListItemViewHolder<ListItem<?>>{
public SimpleListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list, parent);
}
}

View File

@@ -0,0 +1,43 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.views.M3Switch;
import me.grishka.appkit.utils.V;
public class SwitchListItemViewHolder extends CheckableListItemViewHolder{
private final M3Switch sw;
private boolean ignoreListener;
public SwitchListItemViewHolder(Context context, ViewGroup parent){
super(context, parent);
sw=new M3Switch(context);
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(52), V.dp(32));
lp.gravity=Gravity.TOP;
lp.setMarginStart(V.dp(16));
checkableLayout.addView(sw, lp);
sw.setOnCheckedChangeListener((buttonView, isChecked)->{
if(ignoreListener)
return;
if(item.checkedChangeListener!=null)
item.checkedChangeListener.accept(isChecked);
else
item.checked=isChecked;
});
sw.setClickable(true);
}
@Override
public void onBind(CheckableListItem<?> item){
super.onBind(item);
ignoreListener=true;
sw.setChecked(item.checked);
sw.setEnabled(item.isEnabled);
ignoreListener=false;
}
}

View File

@@ -98,7 +98,7 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
errorView=new LinkedTextView(getContext());
errorView.setTextAppearance(R.style.m3_body_small);
errorView.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurfaceVariant));
errorView.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Error));
errorView.setLinkTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary));
errorView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
errorView.setPadding(dp(16), dp(4), dp(16), 0);
@@ -106,6 +106,10 @@ public class FloatingHintEditTextLayout extends FrameLayout implements CustomVie
addView(errorView);
}
public void updateHint(){
label.setText(edit.getHint());
}
private void onTextChanged(Editable text){
if(errorState){
errorView.setVisibility(View.GONE);

View File

@@ -1,8 +1,13 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.webkit.WebView;
import android.widget.ScrollView;
import org.joinmastodon.android.R;
import java.util.function.Supplier;
@@ -10,19 +15,22 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
public class NestedRecyclerScrollView extends CustomScrollView{
private Supplier<RecyclerView> scrollableChildSupplier;
private Supplier<View> scrollableChildSupplier;
private boolean takePriorityOverChildViews;
public NestedRecyclerScrollView(Context context){
super(context);
this(context, null);
}
public NestedRecyclerScrollView(Context context, AttributeSet attrs){
super(context, attrs);
this(context, attrs, 0);
}
public NestedRecyclerScrollView(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
public NestedRecyclerScrollView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.NestedRecyclerScrollView);
takePriorityOverChildViews=ta.getBoolean(R.styleable.NestedRecyclerScrollView_takePriorityOverChildViews, false);
ta.recycle();
}
@Override
@@ -33,7 +41,7 @@ public class NestedRecyclerScrollView extends CustomScrollView{
consumed[1]=dy;
return;
}
}else if((dy<0 && target instanceof RecyclerView rv && isScrolledToTop(rv)) || (dy>0 && !isScrolledToBottom())){
}else if((dy<0 && isScrolledToTop(target)) || (dy>0 && !isScrolledToBottom())){
scrollBy(0, dy);
consumed[1]=dy;
return;
@@ -48,7 +56,7 @@ public class NestedRecyclerScrollView extends CustomScrollView{
fling((int)velY);
return true;
}
}else if((velY<0 && target instanceof RecyclerView rv && isScrolledToTop(rv)) || (velY>0 && !isScrolledToBottom())){
}else if((velY<0 && isScrolledToTop(target)) || (velY>0 && !isScrolledToBottom())){
fling((int) velY);
return true;
}
@@ -59,22 +67,40 @@ public class NestedRecyclerScrollView extends CustomScrollView{
return !canScrollVertically(1);
}
private boolean isScrolledToTop(RecyclerView rv){
final LinearLayoutManager lm=(LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition()==0
&& lm.findViewByPosition(0).getTop()==rv.getPaddingTop();
private boolean isScrolledToTop(View view){
if(view instanceof RecyclerView rv){
final LinearLayoutManager lm=(LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition()==0
&& lm.findViewByPosition(0).getTop()==rv.getPaddingTop();
}
return !view.canScrollVertically(-1);
}
public void setScrollableChildSupplier(Supplier<RecyclerView> scrollableChildSupplier){
public void setScrollableChildSupplier(Supplier<View> scrollableChildSupplier){
this.scrollableChildSupplier=scrollableChildSupplier;
}
@Override
protected boolean onScrollingHitEdge(float velocity){
if(velocity>0 || takePriorityOverChildViews){
RecyclerView view=scrollableChildSupplier.get();
if(view!=null){
return view.fling(0, (int) velocity);
View view=scrollableChildSupplier==null ? null : scrollableChildSupplier.get();
if(view instanceof RecyclerView rv){
return rv.fling(0, (int) velocity);
}else if(view instanceof ScrollView sv){
if(sv.canScrollVertically((int)velocity)){
sv.fling((int)velocity);
return true;
}
}else if(view instanceof CustomScrollView sv){
if(sv.canScrollVertically((int)velocity)){
sv.fling((int)velocity);
return true;
}
}else if(view instanceof WebView wv){
if(wv.canScrollVertically((int)velocity)){
wv.flingScroll(0, (int)velocity);
return true;
}
}
}
return false;

View File

@@ -7,6 +7,7 @@ import org.joinmastodon.android.BuildConfig;
public abstract class GithubSelfUpdater{
private static GithubSelfUpdater instance;
public static boolean forceUpdate;
public static GithubSelfUpdater getInstance(){
if(instance==null){
@@ -20,7 +21,7 @@ public abstract class GithubSelfUpdater{
}
public static boolean needSelfUpdating(){
return BuildConfig.BUILD_TYPE.equals("githubRelease");
return BuildConfig.BUILD_TYPE.equals("githubRelease") || BuildConfig.BUILD_TYPE.equals("githubDebug");
}
public abstract void maybeCheckForUpdates();
@@ -39,6 +40,8 @@ public abstract class GithubSelfUpdater{
public abstract void handleIntentFromInstaller(Intent intent, Activity activity);
public abstract void reset();
public enum UpdateState{
NO_UPDATE,
CHECKING,

View File

@@ -5,6 +5,7 @@ import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.view.View;
@@ -27,6 +28,7 @@ public class ElevationOnScrollListener extends RecyclerView.OnScrollListener imp
private Animator currentPanelsAnim;
private List<View> views;
private FragmentRootLinearLayout fragmentRootLayout;
private Rect tmpRect=new Rect();
public ElevationOnScrollListener(FragmentRootLinearLayout fragmentRootLayout, View... views){
this(fragmentRootLayout, Arrays.asList(views));
@@ -70,9 +72,14 @@ public class ElevationOnScrollListener extends RecyclerView.OnScrollListener imp
}
}
private int getRecyclerChildDecoratedTop(RecyclerView rv, View child){
rv.getDecoratedBoundsWithMargins(child, tmpRect);
return tmpRect.top;
}
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
boolean newAtTop=recyclerView.getChildCount()==0 || (recyclerView.getChildAdapterPosition(recyclerView.getChildAt(0))==0 && recyclerView.getChildAt(0).getTop()==recyclerView.getPaddingTop());
boolean newAtTop=recyclerView.getChildCount()==0 || (recyclerView.getChildAdapterPosition(recyclerView.getChildAt(0))==0 && getRecyclerChildDecoratedTop(recyclerView, recyclerView.getChildAt(0))==recyclerView.getPaddingTop());
handleScroll(recyclerView.getContext(), newAtTop);
}
@@ -120,4 +127,8 @@ public class ElevationOnScrollListener extends RecyclerView.OnScrollListener imp
currentPanelsAnim=set;
}
}
public int getCurrentStatusBarColor(){
return fragmentRootLayout.getStatusBarColor();
}
}

View File

@@ -1,7 +1,8 @@
package org.joinmastodon.android.utils;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Status;
import java.util.List;
@@ -9,19 +10,19 @@ import java.util.function.Predicate;
import java.util.stream.Collectors;
public class StatusFilterPredicate implements Predicate<Status>{
private final List<Filter> filters;
private final List<LegacyFilter> filters;
public StatusFilterPredicate(List<Filter> filters){
public StatusFilterPredicate(List<LegacyFilter> filters){
this.filters=filters;
}
public StatusFilterPredicate(String accountID, Filter.FilterContext context){
public StatusFilterPredicate(String accountID, FilterContext context){
filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(context)).collect(Collectors.toList());
}
@Override
public boolean test(Status status){
for(Filter filter:filters){
for(LegacyFilter filter:filters){
if(filter.matches(status))
return false;
}

View File

@@ -0,0 +1,31 @@
package org.joinmastodon.android.utils;
import android.graphics.drawable.Drawable;
import android.view.View;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ViewImageLoader;
public class ViewImageLoaderHolderTarget implements ViewImageLoader.Target{
private final ImageLoaderViewHolder holder;
private final int imageIndex;
public ViewImageLoaderHolderTarget(ImageLoaderViewHolder holder, int imageIndex){
this.holder=holder;
this.imageIndex=imageIndex;
}
@Override
public void setImageDrawable(Drawable d){
if(d==null)
holder.clearImage(imageIndex);
else
holder.setImage(imageIndex, d);
}
@Override
public View getView(){
return ((RecyclerView.ViewHolder)holder).itemView;
}
}

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="200"
android:interpolator="@android:anim/decelerate_interpolator"
android:interpolator="@interpolator/m3_sys_motion_easing_standard_decelerate"
android:fromAlpha="1.0"
android:toAlpha="0.0"/>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorM3Primary" android:state_selected="true"/>
<item android:color="?colorM3OnSurfaceVariant"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<stroke android:width="1dp" android:color="?colorM3OutlineVariant"/>
<corners android:radius="12dp"/>
</shape>

View File

@@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="?colorM3OnPrimary"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View File

@@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,16V10.167C3,9.836 3.11,9.564 3.33,9.35C3.57,9.117 3.86,9 4.2,9H7.8C8.14,9 8.42,9.117 8.64,9.35C8.88,9.564 9,9.836 9,10.167V16H7.2V14.25H4.8V16H3ZM4.8,12.5H7.2V10.75H4.8V12.5Z"
android:fillColor="#49454F"
android:fillType="evenOdd"/>
<path
android:pathData="M17.1,10.75V16H18.9V10.75H21V9H15V10.75H17.1Z"
android:fillColor="#49454F"/>
<path
android:pathData="M10.2,9V16H15V14.25H12V9H10.2Z"
android:fillColor="#49454F"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M9,22Q7.55,22 6.275,21.45Q5,20.9 4.05,19.95Q3.1,19 2.55,17.725Q2,16.45 2,15Q2,12.975 3.05,11.3Q4.1,9.625 5.8,8.75Q6.3,7.775 7.038,7.037Q7.775,6.3 8.75,5.8Q9.575,4.1 11.275,3.05Q12.975,2 15,2Q16.45,2 17.725,2.55Q19,3.1 19.95,4.05Q20.9,5 21.45,6.275Q22,7.55 22,9Q22,11.125 20.95,12.75Q19.9,14.375 18.2,15.25Q17.7,16.225 16.962,16.962Q16.225,17.7 15.25,18.2Q14.375,19.9 12.7,20.95Q11.025,22 9,22ZM9,20Q9.825,20 10.588,19.75Q11.35,19.5 12,19Q10.55,19 9.275,18.45Q8,17.9 7.05,16.95Q6.1,16 5.55,14.725Q5,13.45 5,12Q4.5,12.65 4.25,13.412Q4,14.175 4,15Q4,16.05 4.4,16.95Q4.8,17.85 5.475,18.525Q6.15,19.2 7.05,19.6Q7.95,20 9,20ZM12,17Q12.825,17 13.613,16.75Q14.4,16.5 15.05,16Q13.575,16 12.3,15.438Q11.025,14.875 10.075,13.925Q9.125,12.975 8.562,11.7Q8,10.425 8,8.95Q7.5,9.6 7.25,10.387Q7,11.175 7,12Q7,13.05 7.388,13.95Q7.775,14.85 8.475,15.525Q9.15,16.225 10.05,16.613Q10.95,17 12,17ZM15,14Q15.45,14 15.863,13.925Q16.275,13.85 16.7,13.7Q17.25,12.2 16.863,10.812Q16.475,9.425 15.525,8.475Q14.575,7.525 13.188,7.137Q11.8,6.75 10.3,7.3Q10.15,7.725 10.075,8.137Q10,8.55 10,9Q10,10.05 10.387,10.95Q10.775,11.85 11.475,12.525Q12.15,13.225 13.05,13.613Q13.95,14 15,14ZM19,12.05Q19.5,11.4 19.75,10.612Q20,9.825 20,9Q20,7.95 19.613,7.05Q19.225,6.15 18.525,5.475Q17.85,4.775 16.95,4.387Q16.05,4 15,4Q14.125,4 13.363,4.25Q12.6,4.5 11.95,5Q13.425,5 14.7,5.562Q15.975,6.125 16.925,7.075Q17.875,8.025 18.438,9.3Q19,10.575 19,12.05Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M4,22Q3.175,22 2.588,21.413Q2,20.825 2,20V4Q2,3.175 2.588,2.587Q3.175,2 4,2H12L18,8V12.25H16V9H11V4H4Q4,4 4,4Q4,4 4,4V20Q4,20 4,20Q4,20 4,20H15V22ZM4,20V12.25V9V4Q4,4 4,4Q4,4 4,4V20Q4,20 4,20Q4,20 4,20ZM5,19Q5.1,17.775 5.75,16.75Q6.4,15.725 7.45,15.125L6.5,13.425Q6.5,13.4 6.6,13.05Q6.725,13 6.838,13Q6.95,13 7,13.125L7.975,14.875Q8.475,14.675 8.975,14.562Q9.475,14.45 10,14.45Q10.525,14.45 11.025,14.562Q11.525,14.675 12.025,14.875L13,13.125Q13,13.125 13.375,13.025Q13.5,13.075 13.525,13.2Q13.55,13.325 13.5,13.425L12.55,15.125Q13.6,15.725 14.25,16.75Q14.9,17.775 15,19ZM7.75,17.5Q7.95,17.5 8.1,17.35Q8.25,17.2 8.25,17Q8.25,16.8 8.1,16.65Q7.95,16.5 7.75,16.5Q7.55,16.5 7.4,16.65Q7.25,16.8 7.25,17Q7.25,17.2 7.4,17.35Q7.55,17.5 7.75,17.5ZM12.25,17.5Q12.45,17.5 12.6,17.35Q12.75,17.2 12.75,17Q12.75,16.8 12.6,16.65Q12.45,16.5 12.25,16.5Q12.05,16.5 11.9,16.65Q11.75,16.8 11.75,17Q11.75,17.2 11.9,17.35Q12.05,17.5 12.25,17.5ZM20,22 L16,18 17.4,16.575 19,18.15V14H21V18.15L22.6,16.575L24,18Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q507,80 533,83.5Q559,87 584,94Q568,109 555.5,127Q543,145 535,165Q521,163 507.5,161.5Q494,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800Q614,800 707,707Q800,614 800,480Q800,466 798.5,452.5Q797,439 795,425Q815,417 833,404.5Q851,392 866,376Q873,401 876.5,427Q880,453 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480ZM720,360Q670,360 635,325Q600,290 600,240Q600,190 635,155Q670,120 720,120Q770,120 805,155Q840,190 840,240Q840,290 805,325Q770,360 720,360Z"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More