diff --git a/build.gradle b/build.gradle index c61b945e..c4715843 100644 --- a/build.gradle +++ b/build.gradle @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 464f9805..e9cd6f21 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 55bab126..124bd826 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -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}" diff --git a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java index 314fee66..b607c8e4 100644 --- a/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java +++ b/mastodon/src/github/java/org/joinmastodon/android/updater/GithubSelfUpdaterImpl.java @@ -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 diff --git a/mastodon/src/main/assets/server_about_template.htm b/mastodon/src/main/assets/server_about_template.htm new file mode 100644 index 00000000..c3cec86e --- /dev/null +++ b/mastodon/src/main/assets/server_about_template.htm @@ -0,0 +1,51 @@ + + + + + + +{{content}} + + \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 5ffa48d5..5f7eaf8d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -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(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java index 84333cbe..61cd8dee 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java @@ -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); + } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java index 16c45495..cf5123fb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java +++ b/mastodon/src/main/java/org/joinmastodon/android/PushNotificationReceiver.java @@ -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+"") diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index ee56c5a3..eb34a4e4 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -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 filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); + List 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` filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.NOTIFICATIONS)).collect(Collectors.toList()); + List 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` result){ PaginatedResponse> 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; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 5610155b..36071d05 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -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) diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java new file mode 100644 index 00000000..26900f06 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/ResultlessMastodonAPIRequest.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.api; + +import com.google.gson.reflect.TypeToken; + +public abstract class ResultlessMastodonAPIRequest extends MastodonAPIRequest{ + public ResultlessMastodonAPIRequest(HttpMethod method, String path){ + super(method, path, (Class)null); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java new file mode 100644 index 00000000..686b64e3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/UpdateAccountCredentialsPreferences.java @@ -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{ + 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; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java new file mode 100644 index 00000000..ac0477a1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/CreateFilter.java @@ -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{ + public CreateFilter(String title, EnumSet context, FilterAction action, int expiresIn, List 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"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java new file mode 100644 index 00000000..6c5400a8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/DeleteFilter.java @@ -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"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java new file mode 100644 index 00000000..ff61d536 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/FilterRequest.java @@ -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 context; + public FilterAction filterAction; + public Integer expiresIn; + public List keywordsAttributes; + + public FilterRequest(String title, EnumSet context, FilterAction filterAction, Integer expiresIn, List keywordsAttributes){ + this.title=title; + this.context=context; + this.filterAction=filterAction; + this.expiresIn=expiresIn; + this.keywordsAttributes=keywordsAttributes; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java similarity index 52% rename from mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java rename to mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java index 78103595..904d42f9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetWordFilters.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetFilters.java @@ -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>{ - public GetWordFilters(){ +public class GetFilters extends MastodonAPIRequest>{ + public GetFilters(){ super(HttpMethod.GET, "/filters", new TypeToken<>(){}); } + + @Override + protected String getPathPrefix(){ + return "/api/v2"; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java new file mode 100644 index 00000000..eeefe13a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/GetLegacyFilters.java @@ -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>{ + public GetLegacyFilters(){ + super(HttpMethod.GET, "/filters", new TypeToken<>(){}); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java new file mode 100644 index 00000000..d35a0f0f --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/KeywordAttribute.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java new file mode 100644 index 00000000..2c296540 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/filters/UpdateFilter.java @@ -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{ + public UpdateFilter(String id, String title, EnumSet context, FilterAction action, int expiresIn, List words, List deletedWords){ + super(HttpMethod.PUT, "/filters/"+id, Filter.class); + + List 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"; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java new file mode 100644 index 00000000..3d0487e8 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/instance/GetInstanceExtendedDescription.java @@ -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{ + public GetInstanceExtendedDescription(){ + super(HttpMethod.GET, "/instance/extended_description", Response.class); + } + + public static class Response{ + public Instant updatedAt; + public String content; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java new file mode 100644 index 00000000..92b5a26b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountLocalPreferences.java @@ -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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java index 4c4cc03f..158070da 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -1,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 wordFilters=new ArrayList<>(); + public List 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; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java index 7b553d01..3015a63b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSessionManager.java @@ -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 result){ + public void onSuccess(List result){ session.wordFilters=result; session.filtersLastUpdated=System.currentTimeMillis(); writeAccountsFile(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java new file mode 100644 index 00000000..2b85d964 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterCreatedOrUpdatedEvent.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java new file mode 100644 index 00000000..d89069ed --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/SettingsFilterDeletedEvent.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java b/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java new file mode 100644 index 00000000..b4f63a09 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/events/StatusDisplaySettingsChangedEvent.java @@ -0,0 +1,9 @@ +package org.joinmastodon.android.events; + +public class StatusDisplaySettingsChangedEvent{ + public final String accountID; + + public StatusDisplaySettingsChangedEvent(String accountID){ + this.accountID=accountID; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index f1997c0b..6c229b2c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -554,6 +554,14 @@ public abstract class BaseStatusListFragment exten return attachmentViewsPool; } + public void rebuildAllDisplayItems(){ + displayItems.clear(); + for(T item:data){ + displayItems.addAll(buildDisplayItems(item)); + } + adapter.notifyDataSetChanged(); + } + protected void onModifyItemViewHolder(BindableViewHolder holder){} protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 5786595c..037e0816 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -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); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java index 7684c571..4fa683f0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -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(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 67c88be8..453590d1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -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 filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); + List 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 targetList=displayItems.subList(gapPos, gapPos+1); targetList.clear(); List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); - List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); + List 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java index 125f5a2a..68aecec3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsListFragment.java @@ -315,10 +315,12 @@ public class NotificationsListFragment extends BaseStatusListFragment0){ + new SaveMarkers(null, id).exec(accountID); + AccountSessionManager.get(accountID).setNotificationsMarker(id, true); + realUnreadMarker=id; + updateMarkAllReadButton(); + } } private void resetUnreadBackground(){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index 3a98b3d5..efc59ce5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java deleted file mode 100644 index f7d4ac8d..00000000 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java +++ /dev/null @@ -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 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 onChanged; - private boolean enabled=true; - - public SwitchItem(@StringRes int text, @DrawableRes int icon, boolean checked, Consumer onChanged){ - this.text=getString(text); - this.icon=icon; - this.checked=checked; - this.onChanged=onChanged; - } - - public SwitchItem(@StringRes int text, int icon, boolean checked, Consumer 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>{ - @NonNull - @Override - public BindableViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ - //noinspection unchecked - return (BindableViewHolder) 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 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{ - 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 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{ - 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{ - 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 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{ - 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{ - - 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); - } - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index c2d4087d..ac069919 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -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 filterStatuses(List statuses){ - List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.THREAD)).collect(Collectors.toList()); + List 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; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java index 811beda4..aa0fb853 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/ComposeAccountSearchFragment.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java index 0c4a0c43..1bf665fb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/account_list/PaginatedAccountListFragment.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java index 76c804c3..2d3ec3c2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/LocalTimelineFragment.java @@ -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 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java index c72b028a..d54942fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/AccountActivationFragment.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java index 47e07d4d..8b6583c7 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/InstanceRulesFragment.java @@ -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{ - - @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{ - 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())); - } - } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java index 64827084..30c4741e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/OnboardingFollowSuggestionsFragment.java @@ -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{ +public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment{ private String accountID; private Map relationships=Collections.emptyMap(); private GetAccountRelationships relationshipsRequest; @@ -97,7 +97,7 @@ public class OnboardingFollowSuggestionsFragment extends BaseRecyclerFragment(this){ @Override public void onSuccess(List 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 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 implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{ + private class SuggestionViewHolder extends BindableViewHolder 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 extends MastodonRecyclerFragment>{ + protected GenericListItemsAdapter 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(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 item){ + item.toggle(); + rebindItem(item); + } + + protected void rebindItem(ListItem 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java new file mode 100644 index 00000000..783f9af6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/EditFilterFragment.java @@ -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 implements OnBackPressedListener{ + private static final int WORDS_RESULT=370; + private static final int CONTEXT_RESULT=651; + + private Filter filter; + private ListItem durationItem, wordsItem, contextItem; + private CheckableListItem cwItem; + private FloatingHintEditTextLayout titleEditLayout; + private EditText titleEdit; + + private Instant endsAt; + private ArrayList keywords=new ArrayList<>(); + private ArrayList deletedWordIDs=new ArrayList<>(); + private EnumSet 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 options=Arrays.stream(durationOptions).mapToObj(d->UiUtils.formatDuration(getActivity(), d)).collect(Collectors.toCollection(ArrayList::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 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) 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 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 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 context=(EnumSet) result.getSerializable("context"); + if(!context.equals(this.context)){ + this.context=context; + dirty=true; + updateContextItem(); + } + }else if(reqCode==WORDS_RESULT){ + ArrayList 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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterContextFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterContextFragment.java new file mode 100644 index 00000000..233c00cb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterContextFragment.java @@ -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 implements OnBackPressedListener{ + private EnumSet context; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle(R.string.settings_filter_context); + context=(EnumSet) getArguments().getSerializable("context"); + onDataLoaded(Arrays.stream(FilterContext.values()).map(c->{ + CheckableListItem 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 item:data){ + if(((CheckableListItem) item).checked) + context.add(item.parentObject); + } + Bundle args=new Bundle(); + args.putSerializable("context", context); + setResult(true, args); + return false; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java new file mode 100644 index 00000000..52c4f195 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/FilterWordsFragment.java @@ -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 implements OnBackPressedListener{ + private ImageButton fab; + private ActionMode actionMode; + private ArrayList> selectedItems=new ArrayList<>(); + private ArrayList 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 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 item){ + showAlertForWord(item.parentObject); + } + + private void onSelectionModeWordClick(CheckableListItem 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) 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 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 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 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 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 words){ + ArrayList indexes=new ArrayList<>(); + for(int i=0;ii.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 item=data.get(i); + CheckableListItem 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 item=data.get(i); + ListItem 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()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java new file mode 100644 index 00000000..f767e8d7 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsAboutAppFragment.java @@ -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{ + private ListItem 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java new file mode 100644 index 00000000..28181860 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsBehaviorFragment.java @@ -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{ + private ListItem languageItem; + private CheckableListItem 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(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java new file mode 100644 index 00000000..9176908d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDebugFragment.java @@ -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{ + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + setTitle("Debug settings"); + ListItem 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java new file mode 100644 index 00000000..27571a80 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsDisplayFragment.java @@ -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{ + private ImageView themeTransitionWindowView; + private ListItem themeItem; + private CheckableListItem 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{ + @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 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(R.string.settings_add_filter, 0, R.drawable.ic_add_24px, this::onAddFilterClick) + ))); + return adapter; + } + + private void onFilterClick(ListItem 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 makeListItem(Filter f){ + ListItem 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 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java new file mode 100644 index 00000000..83985e79 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsMainFragment.java @@ -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{ + 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(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java new file mode 100644 index 00000000..b8a8d506 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsNotificationsFragment.java @@ -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{ + private PushSubscription pushSubscription; + private CheckableListItem pauseItem; + private ListItem policyItem; + private MergeRecyclerAdapter mergeAdapter; + + private HideableSingleViewRecyclerAdapter bannerAdapter; + private ImageView bannerIcon; + private TextView bannerText; + private Button bannerButton; + + private CheckableListItem mentionsItem, boostsItem, favoritesItem, followersItem, pollsItem; + private List> 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 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_INTSystem.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 item:typeItems){ + item.checked=item.isEnabled=prevValue==PushSubscription.Policy.NONE; + rebindItem(item); + } + } + } + + private void updatePauseItem(){ + long time=AccountSessionManager.get(accountID).getLocalPreferences().getNotificationsPauseEndTime(); + if(timeopenSystemNotificationSettings()); + }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); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java new file mode 100644 index 00000000..c2fdde75 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerAboutFragment.java @@ -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 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 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 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+")"; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java new file mode 100644 index 00000000..4ff696aa --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerFragment.java @@ -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 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{ + @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; + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerRulesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerRulesFragment.java new file mode 100644 index 00000000..198fca6a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/settings/SettingsServerRulesFragment.java @@ -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{ + 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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java index 8a629a29..a768e334 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Filter.java @@ -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 context=EnumSet.noneOf(FilterContext.class); + public String title; + + @RequiredField + public EnumSet context; + public Instant expiresAt; - public boolean irreversible; - public boolean wholeWord; + public FilterAction filterAction; - @SerializedName("context") - private List _context; + @RequiredField + public List keywords; - private transient Pattern pattern; + @RequiredField + public List 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 - } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FilterAction.java b/mastodon/src/main/java/org/joinmastodon/android/model/FilterAction.java new file mode 100644 index 00000000..1fcdcf7d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FilterAction.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.model; + +import com.google.gson.annotations.SerializedName; + +public enum FilterAction{ + @SerializedName("warn") + WARN, + @SerializedName("hide") + HIDE +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FilterContext.java b/mastodon/src/main/java/org/joinmastodon/android/model/FilterContext.java new file mode 100644 index 00000000..1019f3fd --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FilterContext.java @@ -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; + }; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FilterKeyword.java b/mastodon/src/main/java/org/joinmastodon/android/model/FilterKeyword.java new file mode 100644 index 00000000..3446e459 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FilterKeyword.java @@ -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+ + '}'; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/FilterStatus.java b/mastodon/src/main/java/org/joinmastodon/android/model/FilterStatus.java new file mode 100644 index 00000000..ae1e4b99 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/FilterStatus.java @@ -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; +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java b/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java new file mode 100644 index 00000000..7b83aa8d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/LegacyFilter.java @@ -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 context=EnumSet.noneOf(FilterContext.class); + public Instant expiresAt; + public boolean irreversible; + public boolean wholeWord; + + @SerializedName("context") + private List _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+ + '}'; + } + +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java b/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java deleted file mode 100644 index 751b2c0e..00000000 --- a/mastodon/src/main/java/org/joinmastodon/android/model/ParsedAccount.java +++ /dev/null @@ -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)); - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java b/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java index 4430bed6..89d9c8fe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Poll.java @@ -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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java index 3472cc08..9e710ffc 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/AccountViewModel.java @@ -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){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java new file mode 100644 index 00000000..cff521a9 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/CheckableListItem.java @@ -0,0 +1,74 @@ +package org.joinmastodon.android.model.viewmodel; + +import org.joinmastodon.android.R; + +import java.util.function.Consumer; + +public class CheckableListItem extends ListItem{ + public Style style; + public boolean checked; + public Consumer 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 + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItem.java b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItem.java new file mode 100644 index 00000000..8f4d9c73 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/viewmodel/ListItem.java @@ -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{ + 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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java index f9b77101..3d3bc147 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java @@ -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){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java index e140d58e..0f386d50 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/M3AlertDialogBuilder.java @@ -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; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/OutlineProviders.java b/mastodon/src/main/java/org/joinmastodon/android/ui/OutlineProviders.java index 81f05df3..afe2ab91 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/OutlineProviders.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/OutlineProviders.java @@ -10,6 +10,7 @@ import me.grishka.appkit.utils.V; public class OutlineProviders{ private static final SparseArray roundedRects=new SparseArray<>(); private static final SparseArray topRoundedRects=new SparseArray<>(); + private static final SparseArray bottomRoundedRects=new SparseArray<>(); private static final SparseArray 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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java new file mode 100644 index 00000000..d5b5655b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/GenericListItemsAdapter.java @@ -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 extends RecyclerView.Adapter>{ + private List> items; + + public GenericListItemsAdapter(List> 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>)holder).bind(items.get(position)); + } + + @Override + public int getItemCount(){ + return items.size(); + } + + @Override + public int getItemViewType(int position){ + return items.get(position).getItemViewType(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/InstanceRulesAdapter.java b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/InstanceRulesAdapter.java new file mode 100644 index 00000000..3a3c507e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/adapters/InstanceRulesAdapter.java @@ -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{ + private final List rules; + + public InstanceRulesAdapter(List 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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountStatusDisplayItem.java index 39c46498..b4666100 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AccountStatusDisplayItem.java @@ -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)); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java index 83d1360e..45439304 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/AudioStatusDisplayItem.java @@ -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())); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java index 6c567917..ed4b4359 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java @@ -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); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java index 38c3403a..bffd9f51 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java index c81b38ca..0565bec3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/HeaderStatusDisplayItem.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index 782acc76..6572fe89 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -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 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 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)); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/MediaAttachmentViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/MediaAttachmentViewController.java index a2a0a1da..5b7dfb1b 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/MediaAttachmentViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/MediaAttachmentViewController.java @@ -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; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index a8ed5d22..3a9577fd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -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 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); + } + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java index 4d89e0e0..5ccf39c6 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java @@ -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 users=Collections.emptyList(); + private List users=Collections.emptyList(); private List hashtags=Collections.emptyList(); private List emojis=Collections.emptyList(); private Mode mode; @@ -226,8 +227,8 @@ public class ComposeAutocompleteViewController{ @Override public void onSuccess(SearchResults result){ currentRequest=null; - List oldList=users; - users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList()); + List 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 implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{ + private class UserViewHolder extends BindableViewHolder 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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java index badcdd50..ff3239ce 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeLanguageAlertViewController.java @@ -89,10 +89,32 @@ public class ComposeLanguageAlertViewController{ } if(previouslySelected!=null){ - if((previouslySelected.index{ 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()) diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java index 5cec3b0f..6dca58ce 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/AccountViewHolder.java @@ -110,6 +110,8 @@ public class AccountViewHolder extends BindableViewHolder 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 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/CheckableListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/CheckableListItemViewHolder.java new file mode 100644 index 00000000..d649be96 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/CheckableListItemViewHolder.java @@ -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>{ + 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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/CheckboxOrRadioListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/CheckboxOrRadioListItemViewHolder.java new file mode 100644 index 00000000..a91e0c1e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/CheckboxOrRadioListItemViewHolder.java @@ -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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/InstanceRuleViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/InstanceRuleViewHolder.java new file mode 100644 index 00000000..31e8595b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/InstanceRuleViewHolder.java @@ -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{ + 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)); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/ListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/ListItemViewHolder.java new file mode 100644 index 00000000..7b138c48 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/ListItemViewHolder.java @@ -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> extends BindableViewHolder 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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SimpleListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SimpleListItemViewHolder.java new file mode 100644 index 00000000..901e87c2 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SimpleListItemViewHolder.java @@ -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>{ + public SimpleListItemViewHolder(Context context, ViewGroup parent){ + super(context, R.layout.item_generic_list, parent); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java new file mode 100644 index 00000000..85cefd00 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewholders/SwitchListItemViewHolder.java @@ -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; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java index b02f6865..54746df1 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/FloatingHintEditTextLayout.java @@ -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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestedRecyclerScrollView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestedRecyclerScrollView.java index 10c8e19b..bdcd3182 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestedRecyclerScrollView.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/NestedRecyclerScrollView.java @@ -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 scrollableChildSupplier; + private Supplier 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 scrollableChildSupplier){ + public void setScrollableChildSupplier(Supplier 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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java b/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java index 810c516d..7e26508a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java +++ b/mastodon/src/main/java/org/joinmastodon/android/updater/GithubSelfUpdater.java @@ -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, diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/ElevationOnScrollListener.java b/mastodon/src/main/java/org/joinmastodon/android/utils/ElevationOnScrollListener.java index 62f49ff5..160097cb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/ElevationOnScrollListener.java +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/ElevationOnScrollListener.java @@ -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 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(); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java index cf9e0829..c9a6e9a0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/StatusFilterPredicate.java @@ -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{ - private final List filters; + private final List filters; - public StatusFilterPredicate(List filters){ + public StatusFilterPredicate(List 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; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/utils/ViewImageLoaderHolderTarget.java b/mastodon/src/main/java/org/joinmastodon/android/utils/ViewImageLoaderHolderTarget.java new file mode 100644 index 00000000..2725df0d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/utils/ViewImageLoaderHolderTarget.java @@ -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; + } +} diff --git a/mastodon/src/main/res/anim/fade_out_fast.xml b/mastodon/src/main/res/anim/fade_out_fast.xml index de985e23..3f09d564 100644 --- a/mastodon/src/main/res/anim/fade_out_fast.xml +++ b/mastodon/src/main/res/anim/fade_out_fast.xml @@ -1,6 +1,6 @@ \ No newline at end of file diff --git a/mastodon/src/main/res/color/selectable_icon_tint.xml b/mastodon/src/main/res/color/selectable_icon_tint.xml new file mode 100644 index 00000000..83c7d0a6 --- /dev/null +++ b/mastodon/src/main/res/color/selectable_icon_tint.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_settings_banner.xml b/mastodon/src/main/res/drawable/bg_settings_banner.xml new file mode 100644 index 00000000..5e5a2f5a --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_settings_banner.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_actionmode_close.xml b/mastodon/src/main/res/drawable/ic_actionmode_close.xml new file mode 100644 index 00000000..222363cd --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_actionmode_close.xml @@ -0,0 +1,5 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_alt_24px.xml b/mastodon/src/main/res/drawable/ic_alt_24px.xml new file mode 100644 index 00000000..ce9c3b96 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_alt_24px.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/mastodon/src/main/res/drawable/ic_animation_24px.xml b/mastodon/src/main/res/drawable/ic_animation_24px.xml new file mode 100644 index 00000000..2ebcb150 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_animation_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_apk_install_24px.xml b/mastodon/src/main/res/drawable/ic_apk_install_24px.xml new file mode 100644 index 00000000..19fba8f7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_apk_install_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_app_badging_24px.xml b/mastodon/src/main/res/drawable/ic_app_badging_24px.xml new file mode 100644 index 00000000..c626b498 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_app_badging_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_dark_mode_24px.xml b/mastodon/src/main/res/drawable/ic_dark_mode_24px.xml new file mode 100644 index 00000000..ca5cc6a7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_dark_mode_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_dns_24px.xml b/mastodon/src/main/res/drawable/ic_dns_24px.xml new file mode 100644 index 00000000..dcc0d4e6 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_dns_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_emoticon_24px.xml b/mastodon/src/main/res/drawable/ic_emoticon_24px.xml new file mode 100644 index 00000000..f492a254 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_emoticon_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_filter_alt_24px.xml b/mastodon/src/main/res/drawable/ic_filter_alt_24px.xml new file mode 100644 index 00000000..006d21e4 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_filter_alt_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_group_24px.xml b/mastodon/src/main/res/drawable/ic_group_24px.xml new file mode 100644 index 00000000..21147d83 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_group_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_help_fill1_24px.xml b/mastodon/src/main/res/drawable/ic_help_fill1_24px.xml new file mode 100644 index 00000000..6bb3099e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_help_fill1_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_help_selectable.xml b/mastodon/src/main/res/drawable/ic_help_selectable.xml new file mode 100644 index 00000000..a7e8ad04 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_help_selectable.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_info_24px.xml b/mastodon/src/main/res/drawable/ic_info_24px.xml new file mode 100644 index 00000000..367c171e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_info_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_lock_24px.xml b/mastodon/src/main/res/drawable/ic_lock_24px.xml new file mode 100644 index 00000000..62392637 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_lock_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_mail_24px.xml b/mastodon/src/main/res/drawable/ic_mail_24px.xml new file mode 100644 index 00000000..ad21d51d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_mail_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_no_adult_content_24px.xml b/mastodon/src/main/res/drawable/ic_no_adult_content_24px.xml new file mode 100644 index 00000000..acc69102 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_no_adult_content_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_notifications_paused_24px.xml b/mastodon/src/main/res/drawable/ic_notifications_paused_24px.xml new file mode 100644 index 00000000..c0c55d00 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_notifications_paused_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_open_in_browser_24px.xml b/mastodon/src/main/res/drawable/ic_open_in_browser_24px.xml new file mode 100644 index 00000000..2e7b8d01 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_open_in_browser_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_person_remove_24px.xml b/mastodon/src/main/res/drawable/ic_person_remove_24px.xml new file mode 100644 index 00000000..abe85646 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_person_remove_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_settings_24_badged.xml b/mastodon/src/main/res/drawable/ic_settings_24_badged.xml index de24a3c0..9c1148ae 100644 --- a/mastodon/src/main/res/drawable/ic_settings_24_badged.xml +++ b/mastodon/src/main/res/drawable/ic_settings_24_badged.xml @@ -1,10 +1,10 @@ - + - - + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_settings_24px.xml b/mastodon/src/main/res/drawable/ic_settings_24px.xml new file mode 100644 index 00000000..0559faf5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_settings_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_social_leaderboard_24px.xml b/mastodon/src/main/res/drawable/ic_social_leaderboard_24px.xml new file mode 100644 index 00000000..801cefa9 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_social_leaderboard_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_style_24px.xml b/mastodon/src/main/res/drawable/ic_style_24px.xml new file mode 100644 index 00000000..2e47386f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_style_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/layout/alert_title_with_help.xml b/mastodon/src/main/res/layout/alert_title_with_help.xml new file mode 100644 index 00000000..2e0eecda --- /dev/null +++ b/mastodon/src/main/res/layout/alert_title_with_help.xml @@ -0,0 +1,45 @@ + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/alert_title_with_supporting_text.xml b/mastodon/src/main/res/layout/alert_title_with_supporting_text.xml new file mode 100644 index 00000000..27bc56c6 --- /dev/null +++ b/mastodon/src/main/res/layout/alert_title_with_supporting_text.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/floating_hint_edit_text.xml b/mastodon/src/main/res/layout/floating_hint_edit_text.xml new file mode 100644 index 00000000..60d7df2a --- /dev/null +++ b/mastodon/src/main/res/layout/floating_hint_edit_text.xml @@ -0,0 +1,25 @@ + + + + + + diff --git a/mastodon/src/main/res/layout/fragment_settings_server.xml b/mastodon/src/main/res/layout/fragment_settings_server.xml new file mode 100644 index 00000000..e5efdcab --- /dev/null +++ b/mastodon/src/main/res/layout/fragment_settings_server.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_generic_list.xml b/mastodon/src/main/res/layout/item_generic_list.xml new file mode 100644 index 00000000..089e62a8 --- /dev/null +++ b/mastodon/src/main/res/layout/item_generic_list.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_generic_list_checkable.xml b/mastodon/src/main/res/layout/item_generic_list_checkable.xml new file mode 100644 index 00000000..d8145b2e --- /dev/null +++ b/mastodon/src/main/res/layout/item_generic_list_checkable.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_generic_list_content.xml b/mastodon/src/main/res/layout/item_generic_list_content.xml new file mode 100644 index 00000000..b7ee8f6f --- /dev/null +++ b/mastodon/src/main/res/layout/item_generic_list_content.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_settings_banner.xml b/mastodon/src/main/res/layout/item_settings_banner.xml new file mode 100644 index 00000000..0de195da --- /dev/null +++ b/mastodon/src/main/res/layout/item_settings_banner.xml @@ -0,0 +1,77 @@ + + + + + + + + + +