diff --git a/mastodon/build.gradle b/mastodon/build.gradle index b31af568a..8134c5b91 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -10,7 +10,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 31 - versionCode 20 + versionCode 21 versionName "0.1" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java index d2e0f27bc..218c16f62 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ExternalShareActivity.java @@ -2,7 +2,6 @@ package org.joinmastodon.android; import android.app.Fragment; import android.content.ClipData; -import android.content.DialogInterface; import android.content.Intent; import android.graphics.drawable.ColorDrawable; import android.net.Uri; @@ -14,11 +13,11 @@ import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.ComposeFragment; import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; @@ -26,6 +25,7 @@ import me.grishka.appkit.FragmentStackActivity; public class ExternalShareActivity extends FragmentStackActivity{ @Override protected void onCreate(@Nullable Bundle savedInstanceState){ + UiUtils.setUserPreferredTheme(this); super.onCreate(savedInstanceState); if(savedInstanceState==null){ List sessions=AccountSessionManager.getInstance().getLoggedInAccounts(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java new file mode 100644 index 000000000..5ffa48d54 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -0,0 +1,38 @@ +package org.joinmastodon.android; + +import android.content.Context; +import android.content.SharedPreferences; + +public class GlobalUserPreferences{ + public static boolean playGifs; + public static boolean useCustomTabs; + public static boolean trueBlackTheme; + public static ThemePreference theme; + + private static SharedPreferences getPrefs(){ + return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); + } + + public static void load(){ + SharedPreferences prefs=getPrefs(); + playGifs=prefs.getBoolean("playGifs", true); + useCustomTabs=prefs.getBoolean("useCustomTabs", true); + trueBlackTheme=prefs.getBoolean("trueBlackTheme", false); + theme=ThemePreference.values()[prefs.getInt("theme", 0)]; + } + + public static void save(){ + getPrefs().edit() + .putBoolean("playGifs", playGifs) + .putBoolean("useCustomTabs", useCustomTabs) + .putBoolean("trueBlackTheme", trueBlackTheme) + .putInt("theme", theme.ordinal()) + .apply(); + } + + public enum ThemePreference{ + AUTO, + LIGHT, + DARK + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java index b1502e7d7..fb5f85525 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MainActivity.java @@ -15,6 +15,7 @@ import org.joinmastodon.android.fragments.SplashFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.onboarding.AccountActivationFragment; import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; import java.lang.reflect.InvocationTargetException; @@ -25,6 +26,7 @@ import me.grishka.appkit.FragmentStackActivity; public class MainActivity extends FragmentStackActivity{ @Override protected void onCreate(@Nullable Bundle savedInstanceState){ + UiUtils.setUserPreferredTheme(this); super.onCreate(savedInstanceState); if(savedInstanceState==null){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java index 948eb4e13..7e84fb095 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java +++ b/mastodon/src/main/java/org/joinmastodon/android/MastodonApp.java @@ -27,5 +27,6 @@ public class MastodonApp extends Application{ context=getApplicationContext(); PushSubscriptionManager.tryRegisterFCM(); + GlobalUserPreferences.load(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java index 55721d7c4..b5c06de18 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java +++ b/mastodon/src/main/java/org/joinmastodon/android/OAuthActivity.java @@ -16,6 +16,7 @@ import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Token; +import org.joinmastodon.android.ui.utils.UiUtils; import androidx.annotation.Nullable; import me.grishka.appkit.api.Callback; @@ -24,6 +25,7 @@ import me.grishka.appkit.api.ErrorResponse; public class OAuthActivity extends Activity{ @Override protected void onCreate(@Nullable Bundle savedInstanceState){ + UiUtils.setUserPreferredTheme(this); super.onCreate(savedInstanceState); Uri uri=getIntent().getData(); if(uri==null || isTaskRoot()){ 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 6b6a343ec..f66aad420 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -1,7 +1,6 @@ package org.joinmastodon.android.api; import android.content.ContentValues; -import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; @@ -14,8 +13,6 @@ import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; -import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.SearchResult; import org.joinmastodon.android.model.Status; @@ -26,7 +23,6 @@ import java.util.EnumSet; import java.util.List; import java.util.function.Consumer; -import androidx.annotation.Nullable; import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.utils.WorkerThread; @@ -87,7 +83,7 @@ public class CacheController{ .exec(accountID); }catch(SQLiteException x){ Log.w(TAG, x); - uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage()))); + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500))); }finally{ closeDelayed(); } @@ -145,7 +141,7 @@ public class CacheController{ .exec(accountID); }catch(SQLiteException x){ Log.w(TAG, x); - uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage()))); + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500))); }finally{ closeDelayed(); } 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 74d4bd16d..391918e6e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -103,7 +103,7 @@ public class MastodonAPIController{ synchronized(req){ req.okhttpCall=null; } - req.onError(e.getLocalizedMessage()); + req.onError(e.getLocalizedMessage(), 0); } @Override @@ -136,7 +136,7 @@ public class MastodonAPIController{ }catch(JsonIOException|JsonSyntaxException x){ if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error parsing or reading body", x); - req.onError(x.getLocalizedMessage()); + req.onError(x.getLocalizedMessage(), response.code()); return; } @@ -145,7 +145,7 @@ public class MastodonAPIController{ }catch(IOException x){ if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x); - req.onError(x.getLocalizedMessage()); + req.onError(x.getLocalizedMessage(), response.code()); return; } @@ -157,11 +157,11 @@ public class MastodonAPIController{ try{ JsonObject error=JsonParser.parseReader(reader).getAsJsonObject(); Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" received error: "+error); - req.onError(error.get("error").getAsString()); + req.onError(error.get("error").getAsString(), response.code()); }catch(JsonIOException|JsonSyntaxException x){ - req.onError(response.code()+" "+response.message()); + req.onError(response.code()+" "+response.message(), response.code()); }catch(IllegalStateException x){ - req.onError("Error parsing an API error"); + req.onError("Error parsing an API error", response.code()); } } } @@ -170,7 +170,7 @@ public class MastodonAPIController{ }catch(Exception x){ if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] error creating and sending http request", x); - req.onError(x.getLocalizedMessage()); + req.onError(x.getLocalizedMessage(), 0); } }, 0); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index c06f41516..a16319c94 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -2,13 +2,13 @@ package org.joinmastodon.android.api; import android.app.Activity; import android.app.ProgressDialog; -import android.content.DialogInterface; import android.net.Uri; import android.util.Log; import android.util.Pair; import com.google.gson.reflect.TypeToken; +import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.BaseModel; @@ -60,6 +60,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ @Override public synchronized void cancel(){ + if(BuildConfig.DEBUG) + Log.d(TAG, "canceling request "+this); canceled=true; if(okhttpCall!=null){ okhttpCall.cancel(); @@ -181,8 +183,8 @@ public abstract class MastodonAPIRequest extends APIRequest{ } } - void onError(String msg){ - invokeErrorCallback(new MastodonErrorResponse(msg)); + void onError(String msg, int httpStatus){ + invokeErrorCallback(new MastodonErrorResponse(msg, httpStatus)); } void onSuccess(T resp){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java index efb8a848f..74f6ccdde 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonErrorResponse.java @@ -11,9 +11,11 @@ import me.grishka.appkit.api.ErrorResponse; public class MastodonErrorResponse extends ErrorResponse{ public final String error; + public final int httpStatus; - public MastodonErrorResponse(String error){ + public MastodonErrorResponse(String error, int httpStatus){ this.error=error; + this.httpStatus=httpStatus; } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java index 13fcd2a7a..0e8164555 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/PushSubscriptionManager.java @@ -13,6 +13,7 @@ import android.util.Log; import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.RegisterForPushNotifications; +import org.joinmastodon.android.api.requests.notifications.UpdatePushSettings; import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.PushNotification; @@ -119,6 +120,10 @@ public class PushSubscriptionManager{ } public void registerAccountForPush(){ + registerAccountForPush(null); + } + + public void registerAccountForPush(PushSubscription subscription){ if(TextUtils.isEmpty(deviceToken)) throw new IllegalStateException("No device push token available"); MastodonAPIController.runInBackground(()->{ @@ -143,19 +148,22 @@ public class PushSubscriptionManager{ Log.e(TAG, "registerAccountForPush: error generating encryption key", e); return; } - new RegisterForPushNotifications(deviceToken, encodedPublicKey, encodedAuthKey, PushSubscription.Alerts.ofAll(), accountID) + new RegisterForPushNotifications(deviceToken, + encodedPublicKey, + encodedAuthKey, + subscription==null ? PushSubscription.Alerts.ofAll() : subscription.alerts, + subscription==null ? PushSubscription.Policy.ALL : subscription.policy, + accountID) .setCallback(new Callback<>(){ @Override public void onSuccess(PushSubscription result){ MastodonAPIController.runInBackground(()->{ serverKey=deserializeRawPublicKey(Base64.decode(result.serverKey, Base64.URL_SAFE)); - if(serverKey!=null){ - AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); - session.pushServerKey=Base64.encodeToString(serverKey.getEncoded(), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); - AccountSessionManager.getInstance().writeAccountsFile(); - Log.d(TAG, "Successfully registered "+accountID+" for push notifications"); - } + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + session.pushSubscription=result; + AccountSessionManager.getInstance().writeAccountsFile(); + Log.d(TAG, "Successfully registered "+accountID+" for push notifications"); }); } @@ -168,6 +176,34 @@ public class PushSubscriptionManager{ }); } + public void updatePushSettings(PushSubscription subscription){ + new UpdatePushSettings(subscription.alerts, subscription.policy) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(PushSubscription result){ + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + if(result.policy!=subscription.policy) + result.policy=subscription.policy; + session.pushSubscription=result; + session.needUpdatePushSettings=false; + AccountSessionManager.getInstance().writeAccountsFile(); + } + + @Override + public void onError(ErrorResponse error){ + if(((MastodonErrorResponse)error).httpStatus==404){ // Not registered for push, register now + registerAccountForPush(subscription); + }else{ + AccountSession session=AccountSessionManager.getInstance().getAccount(accountID); + session.needUpdatePushSettings=true; + session.pushSubscription=subscription; + AccountSessionManager.getInstance().writeAccountsFile(); + } + } + }) + .exec(accountID); + } + private PublicKey deserializeRawPublicKey(byte[] rawBytes){ if(rawBytes.length!=65 && rawBytes.length!=64) return null; @@ -320,8 +356,10 @@ public class PushSubscriptionManager{ private static void registerAllAccountsForPush(){ for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){ - if(TextUtils.isEmpty(session.pushServerKey)) + if(session.pushSubscription==null) session.getPushSubscriptionManager().registerAccountForPush(); + else if(session.needUpdatePushSettings) + session.getPushSubscriptionManager().updatePushSettings(session.pushSubscription); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java index 6ac126201..5b096f2ba 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/notifications/RegisterForPushNotifications.java @@ -4,11 +4,12 @@ import org.joinmastodon.android.api.MastodonAPIRequest; import org.joinmastodon.android.model.PushSubscription; public class RegisterForPushNotifications extends MastodonAPIRequest{ - public RegisterForPushNotifications(String deviceToken, String encryptionKey, String authKey, PushSubscription.Alerts alerts, String accountID){ + public RegisterForPushNotifications(String deviceToken, String encryptionKey, String authKey, PushSubscription.Alerts alerts, PushSubscription.Policy policy, String accountID){ super(HttpMethod.POST, "/push/subscription", PushSubscription.class); Request r=new Request(); r.subscription.endpoint="https://app.joinmastodon.org/relay-to/fcm/"+deviceToken+"/"+accountID; r.data.alerts=alerts; + r.data.policy=policy; r.subscription.keys.p256dh=encryptionKey; r.subscription.keys.auth=authKey; setRequestBody(r); @@ -30,6 +31,7 @@ public class RegisterForPushNotifications extends MastodonAPIRequest{ + public UpdatePushSettings(PushSubscription.Alerts alerts, PushSubscription.Policy policy){ + super(HttpMethod.PUT, "/push/subscription", PushSubscription.class); + setRequestBody(new Request(alerts, policy)); + } + + private static class Request{ + public Data data=new Data(); + + public Request(PushSubscription.Alerts alerts, PushSubscription.Policy policy){ + this.data.alerts=alerts; + this.data.policy=policy; + } + + private static class Data{ + public PushSubscription.Alerts alerts; + public PushSubscription.Policy policy; + } + } +} 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 0d7542a8d..b8ac38bba 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/session/AccountSession.java @@ -6,7 +6,7 @@ import org.joinmastodon.android.api.PushSubscriptionManager; import org.joinmastodon.android.api.StatusInteractionController; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Application; -import org.joinmastodon.android.model.Instance; +import org.joinmastodon.android.model.PushSubscription; import org.joinmastodon.android.model.Token; public class AccountSession{ @@ -19,7 +19,8 @@ public class AccountSession{ public String pushPrivateKey; public String pushPublicKey; public String pushAuthKey; - public String pushServerKey; + public PushSubscription pushSubscription; + public boolean needUpdatePushSettings; private transient MastodonAPIController apiController; private transient StatusInteractionController statusInteractionController; private transient CacheController cacheController; 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 c1be00279..2535f6aeb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -9,8 +9,10 @@ import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; +import android.util.Log; import android.view.View; import android.view.ViewGroup; +import android.view.WindowInsets; import android.widget.Toolbar; import org.joinmastodon.android.R; @@ -592,6 +594,26 @@ public abstract class BaseStatusListFragment exten return list.getWidth(); } + protected boolean wantsOverlaySystemNavigation(){ + return true; + } + + protected void onSetFabBottomInset(int inset){ + + } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0 && wantsOverlaySystemNavigation()){ + list.setPadding(0, 0, 0, insets.getSystemWindowInsetBottom()); + onSetFabBottomInset(insets.getSystemWindowInsetBottom()); + insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom()); + }else{ + onSetFabBottomInset(0); + } + super.onApplyWindowInsets(insets); + } + protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter> implements ImageLoaderRecyclerAdapter{ public DisplayItemsAdapter(){ 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 b37565478..04cd976e9 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -993,32 +993,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis PopupMenu menu=new PopupMenu(getActivity(), v); menu.inflate(R.menu.compose_visibility); Menu m=menu.getMenu(); - if(Build.VERSION.SDK_INT>=29){ - menu.setForceShowIcon(true); - }else{ - try{ - Method setOptionalIconsVisible=m.getClass().getDeclaredMethod("setOptionalIconsVisible", boolean.class); - setOptionalIconsVisible.setAccessible(true); - setOptionalIconsVisible.invoke(m, true); - }catch(Exception ignore){} - } - ColorStateList iconTint=ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorSecondary)); - for(int i=0;i=26){ - item.setIconTintList(iconTint); - }else{ - icon.setTintList(iconTint); - } - icon=new InsetDrawable(icon, V.dp(8), 0, 0, 0); - item.setIcon(icon); - SpannableStringBuilder ssb=new SpannableStringBuilder(item.getTitle()); - ssb.insert(0, " "); - ssb.setSpan(new SpacerSpan(V.dp(24), 1), 0, 1, 0); - ssb.append(" ", new SpacerSpan(V.dp(8), 1), 0); - item.setTitle(ssb); - } + UiUtils.enablePopupMenuIcons(getActivity(), menu); m.setGroupCheckable(0, true, true); m.findItem(switch(statusVisibility){ case PUBLIC, UNLISTED -> R.id.vis_public; @@ -1113,6 +1088,16 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis finishAutocomplete(); } + @Override + public boolean wantsLightStatusBar(){ + return !UiUtils.isDarkTheme(); + } + + @Override + public boolean wantsLightNavigationBar(){ + return !UiUtils.isDarkTheme(); + } + @Parcel static class DraftMediaAttachment{ public Attachment serverAttachment; diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index b0d2dfdcf..70f132173 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -3,6 +3,7 @@ package org.joinmastodon.android.fragments; import android.app.Activity; import android.os.Bundle; import android.view.View; +import android.view.ViewGroup; import android.widget.ImageButton; import org.joinmastodon.android.R; @@ -13,6 +14,7 @@ import java.util.List; import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.V; public class HashtagTimelineFragment extends StatusListFragment{ private String hashtag; @@ -61,4 +63,9 @@ public class HashtagTimelineFragment extends StatusListFragment{ args.putString("prefilledText", '#'+hashtag+' '); Nav.go(getActivity(), ComposeFragment.class, args); } + + @Override + protected void onSetFabBottomInset(int inset){ + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+inset; + } } 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 7b5762f0e..53f87d909 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeFragment.java @@ -2,10 +2,12 @@ package org.joinmastodon.android.fragments; import android.app.Fragment; import android.app.NotificationManager; +import android.content.Intent; import android.content.res.Configuration; import android.graphics.Outline; import android.os.Build; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -16,18 +18,28 @@ import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; +import org.joinmastodon.android.MainActivity; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.PushNotificationReceiver; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.oauth.RevokeOauthToken; +import org.joinmastodon.android.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.fragments.discover.DiscoverFragment; +import org.joinmastodon.android.fragments.discover.SearchFragment; import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.TabBar; import org.parceler.Parcels; +import java.util.ArrayList; + import androidx.annotation.IdRes; import androidx.annotation.Nullable; import me.grishka.appkit.FragmentStackActivity; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.AppKitFragment; import me.grishka.appkit.fragments.LoaderFragment; import me.grishka.appkit.fragments.OnBackPressedListener; @@ -58,21 +70,23 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N) setRetainInstance(true); - Bundle args=new Bundle(); - args.putString("account", accountID); - homeTimelineFragment=new HomeTimelineFragment(); - homeTimelineFragment.setArguments(args); - args=new Bundle(args); - args.putBoolean("noAutoLoad", true); - searchFragment=new DiscoverFragment(); - searchFragment.setArguments(args); - notificationsFragment=new NotificationsFragment(); - notificationsFragment.setArguments(args); - args=new Bundle(args); - args.putParcelable("profileAccount", Parcels.wrap(AccountSessionManager.getInstance().getAccount(accountID).self)); - args.putBoolean("noAutoLoad", true); - profileFragment=new ProfileFragment(); - profileFragment.setArguments(args); + if(savedInstanceState==null){ + Bundle args=new Bundle(); + args.putString("account", accountID); + homeTimelineFragment=new HomeTimelineFragment(); + homeTimelineFragment.setArguments(args); + args=new Bundle(args); + args.putBoolean("noAutoLoad", true); + searchFragment=new DiscoverFragment(); + searchFragment.setArguments(args); + notificationsFragment=new NotificationsFragment(); + notificationsFragment.setArguments(args); + args=new Bundle(args); + args.putParcelable("profileAccount", Parcels.wrap(AccountSessionManager.getInstance().getAccount(accountID).self)); + args.putBoolean("noAutoLoad", true); + profileFragment=new ProfileFragment(); + profileFragment.setArguments(args); + } } @@ -88,7 +102,7 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene inflater.inflate(R.layout.tab_bar, content); tabBar=content.findViewById(R.id.tabbar); - tabBar.setListener(this::onTabSelected); + tabBar.setListeners(this::onTabSelected, this::onTabLongClick); tabBarWrap=content.findViewById(R.id.tabbar_wrap); tabBarAvatar=tabBar.findViewById(R.id.tab_profile_ava); @@ -123,12 +137,32 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene }); } }else{ - tabBar.selectTab(currentTab); } return content; } + @Override + public void onViewStateRestored(Bundle savedInstanceState){ + super.onViewStateRestored(savedInstanceState); + if(savedInstanceState==null || homeTimelineFragment!=null) + return; + homeTimelineFragment=(HomeTimelineFragment) getChildFragmentManager().getFragment(savedInstanceState, "homeTimelineFragment"); + searchFragment=(DiscoverFragment) getChildFragmentManager().getFragment(savedInstanceState, "searchFragment"); + notificationsFragment=(NotificationsFragment) getChildFragmentManager().getFragment(savedInstanceState, "notificationsFragment"); + profileFragment=(ProfileFragment) getChildFragmentManager().getFragment(savedInstanceState, "profileFragment"); + currentTab=savedInstanceState.getInt("selectedTab"); + Fragment current=fragmentForTab(currentTab); + getChildFragmentManager().beginTransaction() + .hide(homeTimelineFragment) + .hide(searchFragment) + .hide(notificationsFragment) + .hide(profileFragment) + .show(current) + .commit(); + maybeTriggerLoading(current); + } + @Override public void onHiddenChanged(boolean hidden){ super.onHiddenChanged(hidden); @@ -137,12 +171,12 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene @Override public boolean wantsLightStatusBar(){ - return currentTab!=R.id.tab_profile && (MastodonApp.context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)!=Configuration.UI_MODE_NIGHT_YES; + return currentTab!=R.id.tab_profile && !UiUtils.isDarkTheme(); } @Override public boolean wantsLightNavigationBar(){ - return (MastodonApp.context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)!=Configuration.UI_MODE_NIGHT_YES; + return !UiUtils.isDarkTheme(); } @Override @@ -182,6 +216,12 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene return; } getChildFragmentManager().beginTransaction().hide(fragmentForTab(currentTab)).show(newFragment).commit(); + maybeTriggerLoading(newFragment); + currentTab=tab; + ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); + } + + private void maybeTriggerLoading(Fragment newFragment){ if(newFragment instanceof LoaderFragment){ LoaderFragment lf=(LoaderFragment) newFragment; if(!lf.loaded && !lf.dataLoading) @@ -194,8 +234,28 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene NotificationManager nm=getActivity().getSystemService(NotificationManager.class); nm.cancel(accountID, PushNotificationReceiver.NOTIFICATION_ID); } - currentTab=tab; - ((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this); + } + + private boolean onTabLongClick(@IdRes int tab){ + if(tab==R.id.tab_profile){ + ArrayList options=new ArrayList<>(); + for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){ + options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")"); + } + new M3AlertDialogBuilder(getActivity()) + .setItems(options.toArray(new String[0]), (dialog, which)->{ + AccountSession session=AccountSessionManager.getInstance().getLoggedInAccounts().get(which); + AccountSessionManager.getInstance().setLastActiveAccountID(session.getID()); + getActivity().finish(); + getActivity().startActivity(new Intent(getActivity(), MainActivity.class)); + }) + .setNegativeButton(R.string.add_account, (dialog, which)->{ + Nav.go(getActivity(), SplashFragment.class, null); + }) + .show(); + return true; + } + return false; } @Override @@ -206,4 +266,14 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene return searchFragment.onBackPressed(); return false; } + + @Override + public void onSaveInstanceState(Bundle outState){ + super.onSaveInstanceState(outState); + outState.putInt("selectedTab", currentTab); + getChildFragmentManager().putFragment(outState, "homeTimelineFragment", homeTimelineFragment); + getChildFragmentManager().putFragment(outState, "searchFragment", searchFragment); + getChildFragmentManager().putFragment(outState, "notificationsFragment", notificationsFragment); + getChildFragmentManager().putFragment(outState, "profileFragment", profileFragment); + } } 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 37fba1757..f10e5a9ff 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -83,42 +83,9 @@ public class HomeTimelineFragment extends StatusListFragment{ @Override public boolean onOptionsItemSelected(MenuItem item){ - ArrayList options=new ArrayList<>(); - for(AccountSession session:AccountSessionManager.getInstance().getLoggedInAccounts()){ - options.add(session.self.displayName+"\n("+session.self.username+"@"+session.domain+")"); - } - new M3AlertDialogBuilder(getActivity()) - .setItems(options.toArray(new String[0]), (dialog, which)->{ - AccountSession session=AccountSessionManager.getInstance().getLoggedInAccounts().get(which); - AccountSessionManager.getInstance().setLastActiveAccountID(session.getID()); - getActivity().finish(); - getActivity().startActivity(new Intent(getActivity(), MainActivity.class)); - }) - .setPositiveButton(R.string.log_out, (dialog, which)->{ - 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){ - AccountSessionManager.getInstance().removeAccount(session.getID()); - getActivity().finish(); - getActivity().startActivity(new Intent(getActivity(), MainActivity.class)); - } - - @Override - public void onError(ErrorResponse error){ - AccountSessionManager.getInstance().removeAccount(session.getID()); - getActivity().finish(); - getActivity().startActivity(new Intent(getActivity(), MainActivity.class)); - } - }) - .wrapProgress(getActivity(), R.string.loading, false) - .execNoAuth(session.domain); - }) - .setNegativeButton(R.string.add_account, (dialog, which)->{ - Nav.go(getActivity(), SplashFragment.class, null); - }) - .show(); + Bundle args=new Bundle(); + args.putString("account", accountID); + Nav.go(getActivity(), SettingsFragment.class, args); return true; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java index 3a9e4eb82..ab9adfea5 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileAboutFragment.java @@ -8,12 +8,14 @@ import android.graphics.Paint; import android.graphics.drawable.Drawable; import android.graphics.drawable.ShapeDrawable; import android.graphics.drawable.shapes.RoundRectShape; +import android.os.Build; import android.os.Bundle; 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.EditText; import android.widget.TextView; @@ -34,6 +36,7 @@ import androidx.annotation.Nullable; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.fragments.WindowInsetsAwareFragment; import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; import me.grishka.appkit.imageloader.ImageLoaderViewHolder; import me.grishka.appkit.imageloader.ListImageLoaderWrapper; @@ -44,7 +47,7 @@ import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; import me.grishka.appkit.views.UsableRecyclerView; -public class ProfileAboutFragment extends Fragment{ +public class ProfileAboutFragment extends Fragment implements WindowInsetsAwareFragment{ private static final int MAX_FIELDS=4; public UsableRecyclerView list; @@ -111,6 +114,23 @@ public class ProfileAboutFragment extends Fragment{ return fields; } + @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()); + } + } + + @Override + public boolean wantsLightStatusBar(){ + return false; + } + + @Override + public boolean wantsLightNavigationBar(){ + return false; + } + private class AboutAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ public AboutAdapter(){ super(imgLoader); 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 7d4dce44b..ff159f83c 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -33,6 +33,7 @@ import android.widget.RelativeLayout; import android.widget.TextView; import android.widget.Toolbar; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.accounts.GetAccountByID; import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; @@ -115,6 +116,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private String profileAccountID; private boolean refreshing; private View fab; + private WindowInsets childInsets; public ProfileFragment(){ super(R.layout.loader_fragment_overlay_toolbar); @@ -365,16 +367,35 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onApplyWindowInsets(WindowInsets insets){ statusBarHeight=insets.getSystemWindowInsetTop(); - ((ViewGroup.MarginLayoutParams)getToolbar().getLayoutParams()).topMargin=statusBarHeight; - refreshLayout.setProgressViewEndTarget(true, statusBarHeight+refreshLayout.getProgressCircleDiameter()+V.dp(24)); + if(contentView!=null){ + ((ViewGroup.MarginLayoutParams) getToolbar().getLayoutParams()).topMargin=statusBarHeight; + refreshLayout.setProgressViewEndTarget(true, statusBarHeight+refreshLayout.getProgressCircleDiameter()+V.dp(24)); + if(Build.VERSION.SDK_INT>=29 && insets.getTappableElementInsets().bottom==0){ + int insetBottom=insets.getSystemWindowInsetBottom(); + childInsets=insets.inset(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0); + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24)+insetBottom; + applyChildWindowInsets(); + insets=insets.inset(0, 0, 0, insetBottom); + }else{ + ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(24); + } + } super.onApplyWindowInsets(insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), 0, insets.getSystemWindowInsetRight(), insets.getSystemWindowInsetBottom())); } + private void applyChildWindowInsets(){ + if(postsFragment!=null && postsFragment.isAdded() && childInsets!=null){ + postsFragment.onApplyWindowInsets(childInsets); + postsWithRepliesFragment.onApplyWindowInsets(childInsets); + mediaFragment.onApplyWindowInsets(childInsets); + } + } + private void bindHeaderView(){ setTitle(account.displayName); setSubtitle(getResources().getQuantityString(R.plurals.x_posts, account.statusesCount, account.statusesCount)); - ViewImageLoader.load(avatar, null, new UrlImageLoaderRequest(account.avatar, V.dp(100), V.dp(100))); - ViewImageLoader.load(cover, null, new UrlImageLoaderRequest(account.header, 1000, 1000)); + 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); name.setText(ssb); @@ -790,8 +811,19 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList @Override public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){ Fragment fragment=getFragmentForPage(position); - if(!fragment.isAdded()) - getChildFragmentManager().beginTransaction().add(holder.itemView.getId(), getFragmentForPage(position)).commit(); + if(!fragment.isAdded()){ + getChildFragmentManager().beginTransaction().add(holder.itemView.getId(), fragment).commit(); + holder.itemView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + if(fragment.isAdded()){ + holder.itemView.getViewTreeObserver().removeOnPreDrawListener(this); + applyChildWindowInsets(); + } + return true; + } + }); + } } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java new file mode 100644 index 000000000..6d3ce907e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java @@ -0,0 +1,612 @@ +package org.joinmastodon.android.fragments; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.content.res.Configuration; +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.widget.Button; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; + +import org.joinmastodon.android.BuildConfig; +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.requests.oauth.RevokeOauthToken; +import org.joinmastodon.android.api.session.AccountSession; +import org.joinmastodon.android.api.session.AccountSessionManager; +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 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.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.ToolbarFragment; +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 ToolbarFragment{ + 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); + + 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)); + + 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()>0) + 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){ + AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription); + } + } + + 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_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){ + SwitchItem si=(SwitchItem) items.get(index); + 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); + Intent intent=new Intent(getActivity(), MainActivity.class); + startActivity(intent); + getActivity().finish(); + } + + private void clearImageCache(){ + MastodonAPIController.runInBackground(()->{ + Activity activity=getActivity(); + ImageCache.getInstance(getActivity()).clear(); + Toast.makeText(activity, R.string.media_cache_cleared, Toast.LENGTH_SHORT).show(); + }); + } + + 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); + } + + @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; + } + + @Override + public int getViewType(){ + return 4; + } + } + + private class RedHeaderItem extends HeaderItem{ + + public RedHeaderItem(int 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 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(); + 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(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, 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.setSelected(checked); + } + + public void setChecked(boolean checked){ + checkbox.setSelected(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); + } + } +} 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 c8a5946c6..0f843c82c 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 @@ -65,7 +65,7 @@ public class AccountActivationFragment extends AppKitFragment{ @Override public boolean wantsLightStatusBar(){ - return (MastodonApp.context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)!=Configuration.UI_MODE_NIGHT_YES; + return !UiUtils.isDarkTheme(); } @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java index aad751ae6..a6149146d 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/report/ReportAddPostsChoiceFragment.java @@ -281,4 +281,9 @@ public class ReportAddPostsChoiceFragment extends StatusListFragment{ if(ev.reportAccountID.equals(reportAccount.id)) Nav.finish(this); } + + @Override + protected boolean wantsOverlaySystemNavigation(){ + return false; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java index 2d1486e37..e70dfa4aa 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/PushSubscription.java @@ -1,13 +1,20 @@ package org.joinmastodon.android.model; +import com.google.gson.annotations.SerializedName; + import org.joinmastodon.android.api.AllFieldsAreRequired; +import androidx.annotation.NonNull; + @AllFieldsAreRequired -public class PushSubscription extends BaseModel{ +public class PushSubscription extends BaseModel implements Cloneable{ public int id; public String endpoint; public Alerts alerts; public String serverKey; + public Policy policy=Policy.ALL; + + public PushSubscription(){} @Override public String toString(){ @@ -19,7 +26,18 @@ public class PushSubscription extends BaseModel{ '}'; } - public static class Alerts{ + @NonNull + @Override + public PushSubscription clone(){ + PushSubscription copy=null; + try{ + copy=(PushSubscription) super.clone(); + }catch(CloneNotSupportedException ignore){} + copy.alerts=alerts.clone(); + return copy; + } + + public static class Alerts implements Cloneable{ public boolean follow; public boolean favourite; public boolean reblog; @@ -42,5 +60,26 @@ public class PushSubscription extends BaseModel{ ", poll="+poll+ '}'; } + + @NonNull + @Override + public Alerts clone(){ + try{ + return (Alerts) super.clone(); + }catch(CloneNotSupportedException e){ + return null; + } + } + } + + public enum Policy{ + @SerializedName("all") + ALL, + @SerializedName("followed") + FOLLOWED, + @SerializedName("follower") + FOLLOWER, + @SerializedName("none") + NONE } } 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 e21f73515..09bddb373 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 @@ -15,6 +15,7 @@ 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; @@ -52,7 +53,7 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{ super(parentID, parentFragment); this.user=user; this.createdAt=createdAt; - avaRequest=new UrlImageLoaderRequest(user.avatar, V.dp(50), V.dp(50)); + avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? user.avatar : user.avatarStatic, V.dp(50), V.dp(50)); this.accountID=accountID; parsedName=new SpannableStringBuilder(user.displayName); this.status=status; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java index d34462b47..16cd73035 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/CustomEmojiSpan.java @@ -6,6 +6,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.text.style.ReplacementSpan; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.model.Emoji; import androidx.annotation.NonNull; @@ -53,6 +54,6 @@ public class CustomEmojiSpan extends ReplacementSpan{ public UrlImageLoaderRequest createImageLoaderRequest(){ int size=V.dp(20); - return new UrlImageLoaderRequest(emoji.url, size, size); + return new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? emoji.url : emoji.staticUrl, size, size); } } 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 c2164ff14..a300be2be 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 @@ -3,24 +3,34 @@ package org.joinmastodon.android.ui.utils; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.content.res.Configuration; import android.content.res.TypedArray; import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; import android.net.Uri; +import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.provider.OpenableColumns; +import android.text.SpannableStringBuilder; import android.text.Spanned; +import android.view.Menu; +import android.view.MenuItem; import android.view.View; import android.webkit.MimeTypeMap; import android.widget.Button; +import android.widget.PopupMenu; import android.widget.TextView; 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.requests.accounts.SetAccountBlocked; @@ -36,8 +46,10 @@ import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.text.CustomEmojiSpan; +import org.joinmastodon.android.ui.text.SpacerSpan; import java.io.File; +import java.lang.reflect.Method; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; @@ -69,11 +81,14 @@ public class UiUtils{ private UiUtils(){} public static void launchWebBrowser(Context context, String url){ - // TODO setting for custom tabs - new CustomTabsIntent.Builder() - .setShowTitle(true) - .build() - .launchUrl(context, Uri.parse(url)); + if(GlobalUserPreferences.useCustomTabs){ + new CustomTabsIntent.Builder() + .setShowTitle(true) + .build() + .launchUrl(context, Uri.parse(url)); + }else{ + context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } } public static String formatRelativeTimestamp(Context context, Instant instant){ @@ -374,4 +389,48 @@ public class UiUtils{ d.draw(new Canvas(bitmap)); return bitmap; } + + public static void enablePopupMenuIcons(Context context, PopupMenu menu){ + Menu m=menu.getMenu(); + if(Build.VERSION.SDK_INT>=29){ + menu.setForceShowIcon(true); + }else{ + try{ + Method setOptionalIconsVisible=m.getClass().getDeclaredMethod("setOptionalIconsVisible", boolean.class); + setOptionalIconsVisible.setAccessible(true); + setOptionalIconsVisible.invoke(m, true); + }catch(Exception ignore){} + } + ColorStateList iconTint=ColorStateList.valueOf(UiUtils.getThemeColor(context, android.R.attr.textColorSecondary)); + for(int i=0;i=26){ + item.setIconTintList(iconTint); + }else{ + icon.setTintList(iconTint); + } + icon=new InsetDrawable(icon, V.dp(8), 0, 0, 0); + item.setIcon(icon); + SpannableStringBuilder ssb=new SpannableStringBuilder(item.getTitle()); + ssb.insert(0, " "); + ssb.setSpan(new SpacerSpan(V.dp(24), 1), 0, 1, 0); + ssb.append(" ", new SpacerSpan(V.dp(8), 1), 0); + item.setTitle(ssb); + } + } + + 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 LIGHT -> R.style.Theme_Mastodon_Light; + case DARK -> GlobalUserPreferences.trueBlackTheme ? R.style.Theme_Mastodon_Dark_TrueBlack : R.style.Theme_Mastodon_Dark; + }); + } + + public static boolean isDarkTheme(){ + if(GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.AUTO) + return (MastodonApp.context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)==Configuration.UI_MODE_NIGHT_YES; + return GlobalUserPreferences.theme==GlobalUserPreferences.ThemePreference.DARK; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/TabBar.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/TabBar.java index 8f2b83b58..6295d9837 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/views/TabBar.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/TabBar.java @@ -9,6 +9,7 @@ import android.widget.LinearLayout; import org.joinmastodon.android.R; import java.util.function.IntConsumer; +import java.util.function.IntPredicate; import androidx.annotation.IdRes; import androidx.annotation.Nullable; @@ -17,6 +18,7 @@ public class TabBar extends LinearLayout{ @IdRes private int selectedTabID; private IntConsumer listener; + private IntPredicate longClickListener; public TabBar(Context context){ this(context, null); @@ -39,6 +41,7 @@ public class TabBar extends LinearLayout{ child.setSelected(true); } child.setOnClickListener(this::onChildClick); + child.setOnLongClickListener(this::onChildLongClick); } } @@ -51,8 +54,13 @@ public class TabBar extends LinearLayout{ selectedTabID=v.getId(); } - public void setListener(IntConsumer listener){ + private boolean onChildLongClick(View v){ + return longClickListener.test(v.getId()); + } + + public void setListeners(IntConsumer listener, IntPredicate longClickListener){ this.listener=listener; + this.longClickListener=longClickListener; } public void selectTab(int id){ diff --git a/mastodon/src/main/res/anim/fade_out_fast.xml b/mastodon/src/main/res/anim/fade_out_fast.xml new file mode 100644 index 000000000..de985e235 --- /dev/null +++ b/mastodon/src/main/res/anim/fade_out_fast.xml @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable-mdpi/theme_auto.webp b/mastodon/src/main/res/drawable-mdpi/theme_auto.webp new file mode 100644 index 000000000..d0ecd2ea3 Binary files /dev/null and b/mastodon/src/main/res/drawable-mdpi/theme_auto.webp differ diff --git a/mastodon/src/main/res/drawable-mdpi/theme_auto_trueblack.webp b/mastodon/src/main/res/drawable-mdpi/theme_auto_trueblack.webp new file mode 100644 index 000000000..7974b9a15 Binary files /dev/null and b/mastodon/src/main/res/drawable-mdpi/theme_auto_trueblack.webp differ diff --git a/mastodon/src/main/res/drawable-mdpi/theme_dark.webp b/mastodon/src/main/res/drawable-mdpi/theme_dark.webp new file mode 100644 index 000000000..e1b81632f Binary files /dev/null and b/mastodon/src/main/res/drawable-mdpi/theme_dark.webp differ diff --git a/mastodon/src/main/res/drawable-mdpi/theme_dark_trueblack.webp b/mastodon/src/main/res/drawable-mdpi/theme_dark_trueblack.webp new file mode 100644 index 000000000..bd552ff35 Binary files /dev/null and b/mastodon/src/main/res/drawable-mdpi/theme_dark_trueblack.webp differ diff --git a/mastodon/src/main/res/drawable-mdpi/theme_light.webp b/mastodon/src/main/res/drawable-mdpi/theme_light.webp new file mode 100644 index 000000000..1763ea0d5 Binary files /dev/null and b/mastodon/src/main/res/drawable-mdpi/theme_light.webp differ diff --git a/mastodon/src/main/res/drawable-xhdpi/theme_auto.webp b/mastodon/src/main/res/drawable-xhdpi/theme_auto.webp new file mode 100644 index 000000000..603ca4458 Binary files /dev/null and b/mastodon/src/main/res/drawable-xhdpi/theme_auto.webp differ diff --git a/mastodon/src/main/res/drawable-xhdpi/theme_auto_trueblack.webp b/mastodon/src/main/res/drawable-xhdpi/theme_auto_trueblack.webp new file mode 100644 index 000000000..605fdfbe3 Binary files /dev/null and b/mastodon/src/main/res/drawable-xhdpi/theme_auto_trueblack.webp differ diff --git a/mastodon/src/main/res/drawable-xhdpi/theme_dark.webp b/mastodon/src/main/res/drawable-xhdpi/theme_dark.webp new file mode 100644 index 000000000..71d84949e Binary files /dev/null and b/mastodon/src/main/res/drawable-xhdpi/theme_dark.webp differ diff --git a/mastodon/src/main/res/drawable-xhdpi/theme_dark_trueblack.webp b/mastodon/src/main/res/drawable-xhdpi/theme_dark_trueblack.webp new file mode 100644 index 000000000..bf8d302cc Binary files /dev/null and b/mastodon/src/main/res/drawable-xhdpi/theme_dark_trueblack.webp differ diff --git a/mastodon/src/main/res/drawable-xhdpi/theme_light.webp b/mastodon/src/main/res/drawable-xhdpi/theme_light.webp new file mode 100644 index 000000000..d7ab8ea1c Binary files /dev/null and b/mastodon/src/main/res/drawable-xhdpi/theme_light.webp differ diff --git a/mastodon/src/main/res/drawable-xxhdpi/theme_auto.webp b/mastodon/src/main/res/drawable-xxhdpi/theme_auto.webp new file mode 100644 index 000000000..751cd6423 Binary files /dev/null and b/mastodon/src/main/res/drawable-xxhdpi/theme_auto.webp differ diff --git a/mastodon/src/main/res/drawable-xxhdpi/theme_auto_trueblack.webp b/mastodon/src/main/res/drawable-xxhdpi/theme_auto_trueblack.webp new file mode 100644 index 000000000..e8aa3485a Binary files /dev/null and b/mastodon/src/main/res/drawable-xxhdpi/theme_auto_trueblack.webp differ diff --git a/mastodon/src/main/res/drawable-xxhdpi/theme_dark.webp b/mastodon/src/main/res/drawable-xxhdpi/theme_dark.webp new file mode 100644 index 000000000..8a9eee2a1 Binary files /dev/null and b/mastodon/src/main/res/drawable-xxhdpi/theme_dark.webp differ diff --git a/mastodon/src/main/res/drawable-xxhdpi/theme_dark_trueblack.webp b/mastodon/src/main/res/drawable-xxhdpi/theme_dark_trueblack.webp new file mode 100644 index 000000000..f94a324c4 Binary files /dev/null and b/mastodon/src/main/res/drawable-xxhdpi/theme_dark_trueblack.webp differ diff --git a/mastodon/src/main/res/drawable-xxhdpi/theme_light.webp b/mastodon/src/main/res/drawable-xxhdpi/theme_light.webp new file mode 100644 index 000000000..6e641fa85 Binary files /dev/null and b/mastodon/src/main/res/drawable-xxhdpi/theme_light.webp differ diff --git a/mastodon/src/main/res/drawable/bg_inline_button.xml b/mastodon/src/main/res/drawable/bg_inline_button.xml new file mode 100644 index 000000000..1683aa647 --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_inline_button.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_dark_theme_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_dark_theme_24_regular.xml new file mode 100644 index 000000000..7aede3788 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_dark_theme_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_gif_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_gif_24_regular.xml new file mode 100644 index 000000000..a4ca02760 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_gif_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_link_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_link_24_regular.xml new file mode 100644 index 000000000..88c1eb2e7 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_link_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_person_add_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_person_add_24_regular.xml new file mode 100644 index 000000000..33db132b1 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_person_add_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_prohibited_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_prohibited_24_regular.xml new file mode 100644 index 000000000..e971bcf5d --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_prohibited_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_ntf_logo.xml b/mastodon/src/main/res/drawable/ic_ntf_logo.xml index eeefbbd17..b6bdf73b5 100644 --- a/mastodon/src/main/res/drawable/ic_ntf_logo.xml +++ b/mastodon/src/main/res/drawable/ic_ntf_logo.xml @@ -5,6 +5,6 @@ android:viewportHeight="24"> diff --git a/mastodon/src/main/res/layout/item_settings_footer.xml b/mastodon/src/main/res/layout/item_settings_footer.xml new file mode 100644 index 000000000..1f39e51e0 --- /dev/null +++ b/mastodon/src/main/res/layout/item_settings_footer.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_settings_header.xml b/mastodon/src/main/res/layout/item_settings_header.xml new file mode 100644 index 000000000..f73ce0ba0 --- /dev/null +++ b/mastodon/src/main/res/layout/item_settings_header.xml @@ -0,0 +1,10 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_settings_notification_policy.xml b/mastodon/src/main/res/layout/item_settings_notification_policy.xml new file mode 100644 index 000000000..d18251493 --- /dev/null +++ b/mastodon/src/main/res/layout/item_settings_notification_policy.xml @@ -0,0 +1,37 @@ + + + + + +