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

@ -5,7 +5,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:7.1.3"
classpath "com.android.tools.build:gradle:7.4.2"
classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View File

@ -1,6 +1,6 @@
#Thu Jan 13 11:33:43 MSK 2022
#Sat Jun 03 23:40:27 MSK 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@ -9,7 +9,7 @@ android {
applicationId "org.joinmastodon.android"
minSdk 23
targetSdk 33
versionCode 55
versionCode 56
versionName "1.3.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
resConfigs "ar-rSA", "be-rBY", "bn-rBD", "bs-rBA", "ca-rES", "cs-rCZ", "da-rDK", "de-rDE", "el-rGR", "es-rES", "eu-rES", "fa-rIR", "fi-rFI", "fil-rPH", "fr-rFR", "ga-rIE", "gd-rGB", "gl-rES", "hi-rIN", "hr-rHR", "hu-rHU", "hy-rAM", "ig-rNG", "in-rID", "is-rIS", "it-rIT", "iw-rIL", "ja-rJP", "kab", "ko-rKR", "my-rMM", "nl-rNL", "no-rNO", "oc-rFR", "pl-rPL", "pt-rBR", "pt-rPT", "ro-rRO", "ru-rRU", "si-rLK", "sl-rSI", "sv-rSE", "th-rTH", "tr-rTR", "uk-rUA", "ur-rIN", "vi-rVN", "zh-rCN", "zh-rTW"
@ -37,6 +37,9 @@ android {
githubRelease{
initWith release
}
githubDebug{
initWith debug
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
@ -53,6 +56,9 @@ android {
githubRelease{
setRoot "src/github"
}
githubDebug{
setRoot "src/github"
}
}
lintOptions{
checkReleaseBuilds false
@ -77,7 +83,7 @@ dependencies {
implementation 'de.psdev:async-otto:1.0.3'
implementation 'org.parceler:parceler-api:1.1.12'
annotationProcessor 'org.parceler:parceler:1.1.12'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
def appCenterSdkVersion = "4.4.2"
appcenterPrivateBetaImplementation "com.microsoft.appcenter:appcenter-crashes:${appCenterSdkVersion}"

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>

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