diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 51073363..9434f10e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -187,7 +187,7 @@ dependencies {
implementation("androidx.preference:preference:1.1.1")
implementation("androidx.recyclerview:recyclerview:1.2.1")
implementation("androidx.slidingpanelayout:slidingpanelayout:1.2.0-beta01")
- implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
+ implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.2.0-alpha01")
implementation("com.github.bumptech.glide:glide:$glideVersion")
implementation("com.google.android.material:material:1.5.0-beta01")
implementation("com.google.code.gson:gson:2.8.9")
diff --git a/app/src/main/java/com/google/android/material/appbar/SubtitleCollapsingToolbarLayout.java b/app/src/main/java/com/google/android/material/appbar/SubtitleCollapsingToolbarLayout.java
new file mode 100644
index 00000000..d26f4e7b
--- /dev/null
+++ b/app/src/main/java/com/google/android/material/appbar/SubtitleCollapsingToolbarLayout.java
@@ -0,0 +1,1246 @@
+package com.google.android.material.appbar;
+
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.Typeface;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.FrameLayout;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.DrawableRes;
+import androidx.annotation.IntRange;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+import androidx.annotation.StyleRes;
+import androidx.appcompat.widget.Toolbar;
+import androidx.core.content.ContextCompat;
+import androidx.core.graphics.drawable.DrawableCompat;
+import androidx.core.math.MathUtils;
+import androidx.core.view.GravityCompat;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+import com.google.android.material.animation.AnimationUtils;
+import com.google.android.material.internal.DescendantOffsetUtils;
+import com.google.android.material.internal.SubtitleCollapsingTextHelper;
+import com.google.android.material.internal.ThemeEnforcement;
+
+import org.lsposed.manager.R;
+
+/**
+ * @see CollapsingToolbarLayout
+ */
+public class SubtitleCollapsingToolbarLayout extends FrameLayout {
+
+ private static final int DEFAULT_SCRIM_ANIMATION_DURATION = 600;
+
+ private boolean refreshToolbar = true;
+ private int toolbarId;
+ @Nullable
+ private Toolbar toolbar;
+ @Nullable
+ private View toolbarDirectChild;
+ private View dummyView;
+
+ private int expandedMarginStart;
+ private int expandedMarginTop;
+ private int expandedMarginEnd;
+ private int expandedMarginBottom;
+
+ private final Rect tmpRect = new Rect();
+ @NonNull
+ final SubtitleCollapsingTextHelper collapsingTextHelper;
+ private boolean collapsingTitleEnabled;
+ private boolean drawCollapsingTitle;
+
+ @Nullable
+ private Drawable contentScrim;
+ @Nullable
+ Drawable statusBarScrim;
+ private int scrimAlpha;
+ private boolean scrimsAreShown;
+ private ValueAnimator scrimAnimator;
+ private long scrimAnimationDuration;
+ private int scrimVisibleHeightTrigger = -1;
+
+ private AppBarLayout.OnOffsetChangedListener onOffsetChangedListener;
+
+ int currentOffset;
+
+ @Nullable
+ WindowInsetsCompat lastInsets;
+
+ public SubtitleCollapsingToolbarLayout(@NonNull Context context) {
+ this(context, null);
+ }
+
+ public SubtitleCollapsingToolbarLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public SubtitleCollapsingToolbarLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+
+ collapsingTextHelper = new SubtitleCollapsingTextHelper(this);
+ collapsingTextHelper.setTextSizeInterpolator(AnimationUtils.DECELERATE_INTERPOLATOR);
+
+ TypedArray a = ThemeEnforcement.obtainStyledAttributes(
+ context,
+ attrs,
+ R.styleable.SubtitleCollapsingToolbarLayout,
+ defStyleAttr,
+ R.style.Widget_Design_SubtitleCollapsingToolbar);
+
+ collapsingTextHelper.setExpandedTextGravity(a.getInt(
+ R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleGravity,
+ GravityCompat.START | Gravity.BOTTOM));
+ collapsingTextHelper.setCollapsedTextGravity(a.getInt(
+ R.styleable.SubtitleCollapsingToolbarLayout_collapsedTitleGravity,
+ GravityCompat.START | Gravity.CENTER_VERTICAL));
+
+ expandedMarginStart = expandedMarginTop = expandedMarginEnd = expandedMarginBottom =
+ a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMargin, 0);
+
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginStart)) {
+ expandedMarginStart =
+ a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginStart, 0);
+ }
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd)) {
+ expandedMarginEnd =
+ a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd, 0);
+ }
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginTop)) {
+ expandedMarginTop =
+ a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginTop, 0);
+ }
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom)) {
+ expandedMarginBottom =
+ a.getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom, 0);
+ }
+
+ collapsingTitleEnabled = a.getBoolean(R.styleable.SubtitleCollapsingToolbarLayout_titleEnabled, true);
+ setTitle(a.getText(R.styleable.SubtitleCollapsingToolbarLayout_title));
+ setSubtitle(a.getText(R.styleable.SubtitleCollapsingToolbarLayout_subtitle));
+
+ // First load the default text appearances
+ collapsingTextHelper.setExpandedTitleTextAppearance(
+ R.style.TextAppearance_Design_SubtitleCollapsingToolbar_ExpandedTitle);
+ collapsingTextHelper.setCollapsedTitleTextAppearance(
+ androidx.appcompat.R.style.TextAppearance_AppCompat_Widget_ActionBar_Title);
+ collapsingTextHelper.setExpandedSubtitleTextAppearance(
+ R.style.TextAppearance_Design_SubtitleCollapsingToolbar_ExpandedSubtitle);
+ collapsingTextHelper.setCollapsedSubtitleTextAppearance(
+ androidx.appcompat.R.style.TextAppearance_AppCompat_Widget_ActionBar_Subtitle);
+
+ // Now overlay any custom text appearances
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleTextAppearance)) {
+ collapsingTextHelper.setExpandedTitleTextAppearance(
+ a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_expandedTitleTextAppearance, 0));
+ }
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_collapsedTitleTextAppearance)) {
+ collapsingTextHelper.setCollapsedTitleTextAppearance(
+ a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_collapsedTitleTextAppearance, 0));
+ }
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_expandedSubtitleTextAppearance)) {
+ collapsingTextHelper.setExpandedSubtitleTextAppearance(
+ a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_expandedSubtitleTextAppearance, 0));
+ }
+ if (a.hasValue(R.styleable.SubtitleCollapsingToolbarLayout_collapsedSubtitleTextAppearance)) {
+ collapsingTextHelper.setCollapsedSubtitleTextAppearance(
+ a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_collapsedSubtitleTextAppearance, 0));
+ }
+
+ scrimVisibleHeightTrigger = a
+ .getDimensionPixelSize(R.styleable.SubtitleCollapsingToolbarLayout_scrimVisibleHeightTrigger, -1);
+
+ scrimAnimationDuration = a.getInt(
+ R.styleable.SubtitleCollapsingToolbarLayout_scrimAnimationDuration,
+ DEFAULT_SCRIM_ANIMATION_DURATION);
+
+ setContentScrim(a.getDrawable(R.styleable.SubtitleCollapsingToolbarLayout_contentScrim));
+ setStatusBarScrim(a.getDrawable(R.styleable.SubtitleCollapsingToolbarLayout_statusBarScrim));
+
+ toolbarId = a.getResourceId(R.styleable.SubtitleCollapsingToolbarLayout_toolbarId, -1);
+
+ a.recycle();
+
+ setWillNotDraw(false);
+ }
+
+ @Override
+ protected void onAttachedToWindow() {
+ super.onAttachedToWindow();
+
+ // Add an OnOffsetChangedListener if possible
+ final ViewParent parent = getParent();
+ if (parent instanceof AppBarLayout) {
+ // Copy over from the ABL whether we should fit system windows
+ ViewCompat.setFitsSystemWindows(this, ViewCompat.getFitsSystemWindows((View) parent));
+
+ if (onOffsetChangedListener == null) {
+ onOffsetChangedListener = new OffsetUpdateListener();
+ }
+ ((AppBarLayout) parent).addOnOffsetChangedListener(onOffsetChangedListener);
+
+ // We're attached, so lets request an inset dispatch
+ ViewCompat.requestApplyInsets(this);
+ }
+ }
+
+ @Override
+ protected void onDetachedFromWindow() {
+ // Remove our OnOffsetChangedListener if possible and it exists
+ final ViewParent parent = getParent();
+ if (onOffsetChangedListener != null && parent instanceof AppBarLayout) {
+ ((AppBarLayout) parent).removeOnOffsetChangedListener(onOffsetChangedListener);
+ }
+
+ super.onDetachedFromWindow();
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ super.draw(canvas);
+
+ // If we don't have a toolbar, the scrim will be not be drawn in drawChild() below.
+ // Instead, we draw it here, before our collapsing text.
+ ensureToolbar();
+ if (toolbar == null && contentScrim != null && scrimAlpha > 0) {
+ contentScrim.mutate().setAlpha(scrimAlpha);
+ contentScrim.draw(canvas);
+ }
+
+ // Let the collapsing text helper draw its text
+ if (collapsingTitleEnabled && drawCollapsingTitle) {
+ collapsingTextHelper.draw(canvas);
+ }
+
+ // Now draw the status bar scrim
+ if (statusBarScrim != null && scrimAlpha > 0) {
+ final int topInset = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0;
+ if (topInset > 0) {
+ statusBarScrim.setBounds(0, -currentOffset, getWidth(), topInset - currentOffset);
+ statusBarScrim.mutate().setAlpha(scrimAlpha);
+ statusBarScrim.draw(canvas);
+ }
+ }
+ }
+
+ @Override
+ protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
+ // This is a little weird. Our scrim needs to be behind the Toolbar (if it is present),
+ // but in front of any other children which are behind it. To do this we intercept the
+ // drawChild() call, and draw our scrim just before the Toolbar is drawn
+ boolean invalidated = false;
+ if (contentScrim != null && scrimAlpha > 0 && isToolbarChild(child)) {
+ contentScrim.mutate().setAlpha(scrimAlpha);
+ contentScrim.draw(canvas);
+ invalidated = true;
+ }
+ return super.drawChild(canvas, child, drawingTime) || invalidated;
+ }
+
+ @Override
+ protected void onSizeChanged(int w, int h, int oldw, int oldh) {
+ super.onSizeChanged(w, h, oldw, oldh);
+ if (contentScrim != null) {
+ contentScrim.setBounds(0, 0, w, h);
+ }
+ }
+
+ private void ensureToolbar() {
+ if (!refreshToolbar) {
+ return;
+ }
+
+ // First clear out the current Toolbar
+ this.toolbar = null;
+ toolbarDirectChild = null;
+
+ if (toolbarId != -1) {
+ // If we have an ID set, try and find it and it's direct parent to us
+ this.toolbar = findViewById(toolbarId);
+ if (this.toolbar != null) {
+ toolbarDirectChild = findDirectChild(this.toolbar);
+ }
+ }
+
+ if (this.toolbar == null) {
+ // If we don't have an ID, or couldn't find a Toolbar with the correct ID, try and find
+ // one from our direct children
+ Toolbar toolbar = null;
+ for (int i = 0, count = getChildCount(); i < count; i++) {
+ final View child = getChildAt(i);
+ if (child instanceof Toolbar) {
+ toolbar = (Toolbar) child;
+ break;
+ }
+ }
+ this.toolbar = toolbar;
+ }
+
+ updateDummyView();
+ refreshToolbar = false;
+ }
+
+ private boolean isToolbarChild(View child) {
+ return (toolbarDirectChild == null || toolbarDirectChild == this)
+ ? child == toolbar
+ : child == toolbarDirectChild;
+ }
+
+ /**
+ * Returns the direct child of this layout, which itself is the ancestor of the given view.
+ */
+ @NonNull
+ private View findDirectChild(@NonNull final View descendant) {
+ View directChild = descendant;
+ for (ViewParent p = descendant.getParent(); p != this && p != null; p = p.getParent()) {
+ if (p instanceof View) {
+ directChild = (View) p;
+ }
+ }
+ return directChild;
+ }
+
+ private void updateDummyView() {
+ if (!collapsingTitleEnabled && dummyView != null) {
+ // If we have a dummy view and we have our title disabled, remove it from its parent
+ final ViewParent parent = dummyView.getParent();
+ if (parent instanceof ViewGroup) {
+ ((ViewGroup) parent).removeView(dummyView);
+ }
+ }
+ if (collapsingTitleEnabled && toolbar != null) {
+ if (dummyView == null) {
+ dummyView = new View(getContext());
+ }
+ if (dummyView.getParent() == null) {
+ toolbar.addView(dummyView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ }
+ }
+ }
+
+ @Override
+ protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+ ensureToolbar();
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+ final int mode = MeasureSpec.getMode(heightMeasureSpec);
+ final int topInset = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0;
+ if (mode == MeasureSpec.UNSPECIFIED && topInset > 0) {
+ // If we have a top inset and we're set to wrap_content height we need to make sure
+ // we add the top inset to our height, therefore we re-measure
+ heightMeasureSpec = MeasureSpec.makeMeasureSpec(getMeasuredHeight() + topInset, MeasureSpec.EXACTLY);
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+ }
+
+ // Set our minimum height to enable proper AppBarLayout collapsing
+ if (toolbar != null) {
+ if (toolbarDirectChild == null || toolbarDirectChild == this) {
+ setMinimumHeight(getHeightWithMargins(toolbar));
+ } else {
+ setMinimumHeight(getHeightWithMargins(toolbarDirectChild));
+ }
+ }
+ }
+
+ @Override
+ protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
+ super.onLayout(changed, left, top, right, bottom);
+
+ if (lastInsets != null) {
+ // Shift down any views which are not set to fit system windows
+ final int insetTop = lastInsets.getSystemWindowInsetTop();
+ for (int i = 0, z = getChildCount(); i < z; i++) {
+ final View child = getChildAt(i);
+ if (!ViewCompat.getFitsSystemWindows(child)) {
+ if (child.getTop() < insetTop) {
+ // If the child isn't set to fit system windows but is drawing within
+ // the inset offset it down
+ ViewCompat.offsetTopAndBottom(child, insetTop);
+ }
+ }
+ }
+ }
+
+ // Update our child view offset helpers so that they track the correct layout coordinates
+ for (int i = 0, z = getChildCount(); i < z; i++) {
+ getViewOffsetHelper(getChildAt(i)).onViewLayout();
+ }
+
+ // Update the collapsed bounds by getting its transformed bounds
+ if (collapsingTitleEnabled && dummyView != null) {
+ // We only draw the title if the dummy view is being displayed (Toolbar removes
+ // views if there is no space)
+ drawCollapsingTitle = ViewCompat.isAttachedToWindow(dummyView) && dummyView.getVisibility() == VISIBLE;
+
+ if (drawCollapsingTitle) {
+ final boolean isRtl = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL;
+
+ // Update the collapsed bounds
+ final int maxOffset =
+ getMaxOffsetForPinChild(toolbarDirectChild != null ? toolbarDirectChild : toolbar);
+ DescendantOffsetUtils.getDescendantRect(this, dummyView, tmpRect);
+ collapsingTextHelper.setCollapsedBounds(
+ tmpRect.left + (isRtl ? toolbar.getTitleMarginEnd() : toolbar.getTitleMarginStart()),
+ tmpRect.top + maxOffset + toolbar.getTitleMarginTop(),
+ tmpRect.right - (isRtl ? toolbar.getTitleMarginStart() : toolbar.getTitleMarginEnd()),
+ tmpRect.bottom + maxOffset - toolbar.getTitleMarginBottom());
+
+ // Update the expanded bounds
+ collapsingTextHelper.setExpandedBounds(
+ isRtl ? expandedMarginEnd : expandedMarginStart,
+ tmpRect.top + expandedMarginTop,
+ right - left - (isRtl ? expandedMarginStart : expandedMarginEnd),
+ bottom - top - expandedMarginBottom);
+ // Now recalculate using the new bounds
+ collapsingTextHelper.recalculate();
+ }
+ }
+
+ if (toolbar != null) {
+ if (collapsingTitleEnabled && TextUtils.isEmpty(collapsingTextHelper.getTitle())) {
+ // If we do not currently have a title, try and grab it from the Toolbar
+ setTitle(toolbar.getTitle());
+ setSubtitle(toolbar.getSubtitle());
+ }
+ }
+
+ updateScrimVisibility();
+
+ // Apply any view offsets, this should be done at the very end of layout
+ for (int i = 0, z = getChildCount(); i < z; i++) {
+ getViewOffsetHelper(getChildAt(i)).applyOffsets();
+ }
+ }
+
+ private static int getHeightWithMargins(@NonNull final View view) {
+ final ViewGroup.LayoutParams lp = view.getLayoutParams();
+ if (lp instanceof MarginLayoutParams) {
+ final MarginLayoutParams mlp = (MarginLayoutParams) lp;
+ return view.getMeasuredHeight() + mlp.topMargin + mlp.bottomMargin;
+ }
+ return view.getMeasuredHeight();
+ }
+
+ static ViewOffsetHelper getViewOffsetHelper(View view) {
+ ViewOffsetHelper offsetHelper = (ViewOffsetHelper) view.getTag(com.google.android.material.R.id.view_offset_helper);
+ if (offsetHelper == null) {
+ offsetHelper = new ViewOffsetHelper(view);
+ view.setTag(com.google.android.material.R.id.view_offset_helper, offsetHelper);
+ }
+ return offsetHelper;
+ }
+
+ /**
+ * Sets the title to be displayed by this view, if enabled.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_title
+ * @see #setTitleEnabled(boolean)
+ * @see #getTitle()
+ */
+ public void setTitle(@Nullable CharSequence title) {
+ collapsingTextHelper.setTitle(title);
+ updateContentDescriptionFromTitle();
+ }
+
+ /**
+ * Returns the title currently being displayed by this view. If the title is not enabled, then
+ * this will return {@code null}.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_title
+ */
+ @Nullable
+ public CharSequence getTitle() {
+ return collapsingTitleEnabled ? collapsingTextHelper.getTitle() : null;
+ }
+
+ /**
+ * Sets the subtitle to be displayed by this view, if enabled.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_subtitle
+ * @see #setTitleEnabled(boolean)
+ * @see #getSubtitle()
+ */
+ public void setSubtitle(@Nullable CharSequence subtitle) {
+ collapsingTextHelper.setSubtitle(subtitle);
+ updateContentDescriptionFromTitle();
+ }
+
+ /**
+ * Returns the subtitle currently being displayed by this view. If the title is not enabled, then
+ * this will return {@code null}.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_subtitle
+ */
+ @Nullable
+ public CharSequence getSubtitle() {
+ return collapsingTitleEnabled ? collapsingTextHelper.getSubtitle() : null;
+ }
+
+ /**
+ * Sets whether this view should display its own title and subtitle.
+ *
+ *
The title and subtitle displayed by this view will shrink and grow based on the scroll offset.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_titleEnabled
+ * @see #setTitle(CharSequence)
+ * @see #setSubtitle(CharSequence)
+ * @see #isTitleEnabled()
+ */
+ public void setTitleEnabled(boolean enabled) {
+ if (enabled != collapsingTitleEnabled) {
+ collapsingTitleEnabled = enabled;
+ updateContentDescriptionFromTitle();
+ updateDummyView();
+ requestLayout();
+ }
+ }
+
+ /**
+ * Returns whether this view is currently displaying its own title and subtitle.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_titleEnabled
+ * @see #setTitleEnabled(boolean)
+ */
+ public boolean isTitleEnabled() {
+ return collapsingTitleEnabled;
+ }
+
+ /**
+ * Set whether the content scrim and/or status bar scrim should be shown or not. Any change in the
+ * vertical scroll may overwrite this value. Any visibility change will be animated if this view
+ * has already been laid out.
+ *
+ * @param shown whether the scrims should be shown
+ * @see #getStatusBarScrim()
+ * @see #getContentScrim()
+ */
+ public void setScrimsShown(boolean shown) {
+ setScrimsShown(shown, ViewCompat.isLaidOut(this) && !isInEditMode());
+ }
+
+ /**
+ * Set whether the content scrim and/or status bar scrim should be shown or not. Any change in the
+ * vertical scroll may overwrite this value.
+ *
+ * @param shown whether the scrims should be shown
+ * @param animate whether to animate the visibility change
+ * @see #getStatusBarScrim()
+ * @see #getContentScrim()
+ */
+ public void setScrimsShown(boolean shown, boolean animate) {
+ if (scrimsAreShown != shown) {
+ if (animate) {
+ animateScrim(shown ? 0xFF : 0x0);
+ } else {
+ setScrimAlpha(shown ? 0xFF : 0x0);
+ }
+ scrimsAreShown = shown;
+ }
+ }
+
+ private void animateScrim(int targetAlpha) {
+ ensureToolbar();
+ if (scrimAnimator == null) {
+ scrimAnimator = new ValueAnimator();
+ scrimAnimator.setDuration(scrimAnimationDuration);
+ scrimAnimator.setInterpolator(targetAlpha > scrimAlpha
+ ? AnimationUtils.FAST_OUT_LINEAR_IN_INTERPOLATOR
+ : AnimationUtils.LINEAR_OUT_SLOW_IN_INTERPOLATOR);
+ scrimAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+ @Override
+ public void onAnimationUpdate(ValueAnimator animator) {
+ setScrimAlpha((int) animator.getAnimatedValue());
+ }
+ });
+ } else if (scrimAnimator.isRunning()) {
+ scrimAnimator.cancel();
+ }
+
+ scrimAnimator.setIntValues(scrimAlpha, targetAlpha);
+ scrimAnimator.start();
+ }
+
+ void setScrimAlpha(int alpha) {
+ if (alpha != scrimAlpha) {
+ final Drawable contentScrim = this.contentScrim;
+ if (contentScrim != null && toolbar != null) {
+ ViewCompat.postInvalidateOnAnimation(toolbar);
+ }
+ scrimAlpha = alpha;
+ ViewCompat.postInvalidateOnAnimation(SubtitleCollapsingToolbarLayout.this);
+ }
+ }
+
+ int getScrimAlpha() {
+ return scrimAlpha;
+ }
+
+ /**
+ * Set the drawable to use for the content scrim from resources. Providing null will disable the
+ * scrim functionality.
+ *
+ * @param drawable the drawable to display
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim
+ * @see #getContentScrim()
+ */
+ public void setContentScrim(@Nullable Drawable drawable) {
+ if (contentScrim != drawable) {
+ if (contentScrim != null) {
+ contentScrim.setCallback(null);
+ }
+ contentScrim = drawable != null ? drawable.mutate() : null;
+ if (contentScrim != null) {
+ contentScrim.setBounds(0, 0, getWidth(), getHeight());
+ contentScrim.setCallback(this);
+ contentScrim.setAlpha(scrimAlpha);
+ }
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+ }
+
+ /**
+ * Set the color to use for the content scrim.
+ *
+ * @param color the color to display
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim
+ * @see #getContentScrim()
+ */
+ public void setContentScrimColor(@ColorInt int color) {
+ setContentScrim(new ColorDrawable(color));
+ }
+
+ /**
+ * Set the drawable to use for the content scrim from resources.
+ *
+ * @param resId drawable resource id
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim
+ * @see #getContentScrim()
+ */
+ public void setContentScrimResource(@DrawableRes int resId) {
+ setContentScrim(ContextCompat.getDrawable(getContext(), resId));
+ }
+
+ /**
+ * Returns the drawable which is used for the foreground scrim.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_contentScrim
+ * @see #setContentScrim(Drawable)
+ */
+ @Nullable
+ public Drawable getContentScrim() {
+ return contentScrim;
+ }
+
+ /**
+ * Set the drawable to use for the status bar scrim from resources. Providing null will disable
+ * the scrim functionality.
+ *
+ *
This scrim is only shown when we have been given a top system inset.
+ *
+ * @param drawable the drawable to display
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim
+ * @see #getStatusBarScrim()
+ */
+ public void setStatusBarScrim(@Nullable Drawable drawable) {
+ if (statusBarScrim != drawable) {
+ if (statusBarScrim != null) {
+ statusBarScrim.setCallback(null);
+ }
+ statusBarScrim = drawable != null ? drawable.mutate() : null;
+ if (statusBarScrim != null) {
+ if (statusBarScrim.isStateful()) {
+ statusBarScrim.setState(getDrawableState());
+ }
+ DrawableCompat.setLayoutDirection(statusBarScrim, ViewCompat.getLayoutDirection(this));
+ statusBarScrim.setVisible(getVisibility() == VISIBLE, false);
+ statusBarScrim.setCallback(this);
+ statusBarScrim.setAlpha(scrimAlpha);
+ }
+ ViewCompat.postInvalidateOnAnimation(this);
+ }
+ }
+
+ @Override
+ protected void drawableStateChanged() {
+ super.drawableStateChanged();
+
+ final int[] state = getDrawableState();
+ boolean changed = false;
+
+ Drawable d = statusBarScrim;
+ if (d != null && d.isStateful()) {
+ changed |= d.setState(state);
+ }
+ d = contentScrim;
+ if (d != null && d.isStateful()) {
+ changed |= d.setState(state);
+ }
+ if (collapsingTextHelper != null) {
+ changed |= collapsingTextHelper.setState(state);
+ }
+
+ if (changed) {
+ invalidate();
+ }
+ }
+
+ @Override
+ protected boolean verifyDrawable(Drawable who) {
+ return super.verifyDrawable(who) || who == contentScrim || who == statusBarScrim;
+ }
+
+ @Override
+ public void setVisibility(int visibility) {
+ super.setVisibility(visibility);
+
+ final boolean visible = visibility == VISIBLE;
+ if (statusBarScrim != null && statusBarScrim.isVisible() != visible) {
+ statusBarScrim.setVisible(visible, false);
+ }
+ if (contentScrim != null && contentScrim.isVisible() != visible) {
+ contentScrim.setVisible(visible, false);
+ }
+ }
+
+ /**
+ * Set the color to use for the status bar scrim.
+ *
+ *
This scrim is only shown when we have been given a top system inset.
+ *
+ * @param color the color to display
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim
+ * @see #getStatusBarScrim()
+ */
+ public void setStatusBarScrimColor(@ColorInt int color) {
+ setStatusBarScrim(new ColorDrawable(color));
+ }
+
+ /**
+ * Set the drawable to use for the content scrim from resources.
+ *
+ * @param resId drawable resource id
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim
+ * @see #getStatusBarScrim()
+ */
+ public void setStatusBarScrimResource(@DrawableRes int resId) {
+ setStatusBarScrim(ContextCompat.getDrawable(getContext(), resId));
+ }
+
+ /**
+ * Returns the drawable which is used for the status bar scrim.
+ *
+ * @attr ref R.styleable#SubtitleCollapsingToolbarLayout_statusBarScrim
+ * @see #setStatusBarScrim(Drawable)
+ */
+ @Nullable
+ public Drawable getStatusBarScrim() {
+ return statusBarScrim;
+ }
+
+ /**
+ * Sets the text color and size for the collapsed title from the specified TextAppearance
+ * resource.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedTitleTextAppearance
+ */
+ public void setCollapsedTitleTextAppearance(@StyleRes int resId) {
+ collapsingTextHelper.setCollapsedTitleTextAppearance(resId);
+ }
+
+ /**
+ * Sets the text color of the collapsed title.
+ *
+ * @param color The new text color in ARGB format
+ */
+ public void setCollapsedTitleTextColor(@ColorInt int color) {
+ setCollapsedTitleTextColor(ColorStateList.valueOf(color));
+ }
+
+ /**
+ * Sets the text colors of the collapsed title.
+ *
+ * @param colors ColorStateList containing the new text colors
+ */
+ public void setCollapsedTitleTextColor(@NonNull ColorStateList colors) {
+ collapsingTextHelper.setCollapsedTitleTextColor(colors);
+ }
+
+ /**
+ * Sets the text color and size for the collapsed subtitle from the specified TextAppearance
+ * resource.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedSubtitleTextAppearance
+ */
+ public void setCollapsedSubtitleTextAppearance(@StyleRes int resId) {
+ collapsingTextHelper.setCollapsedSubtitleTextAppearance(resId);
+ }
+
+ /**
+ * Sets the text color of the collapsed subtitle.
+ *
+ * @param color The new text color in ARGB format
+ */
+ public void setCollapsedSubtitleTextColor(@ColorInt int color) {
+ setCollapsedSubtitleTextColor(ColorStateList.valueOf(color));
+ }
+
+ /**
+ * Sets the text colors of the collapsed subtitle.
+ *
+ * @param colors ColorStateList containing the new text colors
+ */
+ public void setCollapsedSubtitleTextColor(@NonNull ColorStateList colors) {
+ collapsingTextHelper.setCollapsedSubtitleTextColor(colors);
+ }
+
+ /**
+ * Sets the horizontal alignment of the collapsed title and the vertical gravity that will be used
+ * when there is extra space in the collapsed bounds beyond what is required for the title itself.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedTitleGravity
+ */
+ public void setCollapsedTitleGravity(int gravity) {
+ collapsingTextHelper.setCollapsedTextGravity(gravity);
+ }
+
+ /**
+ * Returns the horizontal and vertical alignment for title when collapsed.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_collapsedTitleGravity
+ */
+ public int getCollapsedTitleGravity() {
+ return collapsingTextHelper.getCollapsedTextGravity();
+ }
+
+ /**
+ * Sets the text color and size for the expanded title from the specified TextAppearance resource.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleTextAppearance
+ */
+ public void setExpandedTitleTextAppearance(@StyleRes int resId) {
+ collapsingTextHelper.setExpandedTitleTextAppearance(resId);
+ }
+
+ /**
+ * Sets the text color of the expanded title.
+ *
+ * @param color The new text color in ARGB format
+ */
+ public void setExpandedTitleTextColor(@ColorInt int color) {
+ setExpandedTitleTextColor(ColorStateList.valueOf(color));
+ }
+
+ /**
+ * Sets the text colors of the expanded title.
+ *
+ * @param colors ColorStateList containing the new text colors
+ */
+ public void setExpandedTitleTextColor(@NonNull ColorStateList colors) {
+ collapsingTextHelper.setExpandedTitleTextColor(colors);
+ }
+
+ /**
+ * Sets the text color and size for the expanded subtitle from the specified TextAppearance resource.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedSubtitleTextAppearance
+ */
+ public void setExpandedSubtitleTextAppearance(@StyleRes int resId) {
+ collapsingTextHelper.setExpandedSubtitleTextAppearance(resId);
+ }
+
+ /**
+ * Sets the text color of the expanded subtitle.
+ *
+ * @param color The new text color in ARGB format
+ */
+ public void setExpandedSubtitleTextColor(@ColorInt int color) {
+ setExpandedSubtitleTextColor(ColorStateList.valueOf(color));
+ }
+
+ /**
+ * Sets the text colors of the expanded subtitle.
+ *
+ * @param colors ColorStateList containing the new text colors
+ */
+ public void setExpandedSubtitleTextColor(@NonNull ColorStateList colors) {
+ collapsingTextHelper.setExpandedSubtitleTextColor(colors);
+ }
+
+ /**
+ * Sets the horizontal alignment of the expanded title and the vertical gravity that will be used
+ * when there is extra space in the expanded bounds beyond what is required for the title itself.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleGravity
+ */
+ public void setExpandedTitleGravity(int gravity) {
+ collapsingTextHelper.setExpandedTextGravity(gravity);
+ }
+
+ /**
+ * Returns the horizontal and vertical alignment for title when expanded.
+ *
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleGravity
+ */
+ public int getExpandedTitleGravity() {
+ return collapsingTextHelper.getExpandedTextGravity();
+ }
+
+ /**
+ * Set the typeface to use for the collapsed title.
+ *
+ * @param typeface typeface to use, or {@code null} to use the default.
+ */
+ public void setCollapsedTitleTypeface(@Nullable Typeface typeface) {
+ collapsingTextHelper.setCollapsedTitleTypeface(typeface);
+ }
+
+ /**
+ * Returns the typeface used for the collapsed title.
+ */
+ @NonNull
+ public Typeface getCollapsedTitleTypeface() {
+ return collapsingTextHelper.getCollapsedTitleTypeface();
+ }
+
+ /**
+ * Set the typeface to use for the expanded title.
+ *
+ * @param typeface typeface to use, or {@code null} to use the default.
+ */
+ public void setExpandedTitleTypeface(@Nullable Typeface typeface) {
+ collapsingTextHelper.setExpandedTitleTypeface(typeface);
+ }
+
+ /**
+ * Returns the typeface used for the expanded title.
+ */
+ @NonNull
+ public Typeface getExpandedTitleTypeface() {
+ return collapsingTextHelper.getExpandedTitleTypeface();
+ }
+
+ /**
+ * Set the typeface to use for the collapsed title.
+ *
+ * @param typeface typeface to use, or {@code null} to use the default.
+ */
+ public void setCollapsedSubtitleTypeface(@Nullable Typeface typeface) {
+ collapsingTextHelper.setCollapsedSubtitleTypeface(typeface);
+ }
+
+ /**
+ * Returns the typeface used for the collapsed title.
+ */
+ @NonNull
+ public Typeface getCollapsedSubtitleTypeface() {
+ return collapsingTextHelper.getCollapsedSubtitleTypeface();
+ }
+
+ /**
+ * Set the typeface to use for the expanded title.
+ *
+ * @param typeface typeface to use, or {@code null} to use the default.
+ */
+ public void setExpandedSubtitleTypeface(@Nullable Typeface typeface) {
+ collapsingTextHelper.setExpandedSubtitleTypeface(typeface);
+ }
+
+ /**
+ * Returns the typeface used for the expanded title.
+ */
+ @NonNull
+ public Typeface getExpandedSubtitleTypeface() {
+ return collapsingTextHelper.getExpandedSubtitleTypeface();
+ }
+
+ /**
+ * Sets the expanded title margins.
+ *
+ * @param start the starting title margin in pixels
+ * @param top the top title margin in pixels
+ * @param end the ending title margin in pixels
+ * @param bottom the bottom title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMargin
+ * @see #getExpandedTitleMarginStart()
+ * @see #getExpandedTitleMarginTop()
+ * @see #getExpandedTitleMarginEnd()
+ * @see #getExpandedTitleMarginBottom()
+ */
+ public void setExpandedTitleMargin(int start, int top, int end, int bottom) {
+ expandedMarginStart = start;
+ expandedMarginTop = top;
+ expandedMarginEnd = end;
+ expandedMarginBottom = bottom;
+ requestLayout();
+ }
+
+ /**
+ * @return the starting expanded title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginStart
+ * @see #setExpandedTitleMarginStart(int)
+ */
+ public int getExpandedTitleMarginStart() {
+ return expandedMarginStart;
+ }
+
+ /**
+ * Sets the starting expanded title margin in pixels.
+ *
+ * @param margin the starting title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginStart
+ * @see #getExpandedTitleMarginStart()
+ */
+ public void setExpandedTitleMarginStart(int margin) {
+ expandedMarginStart = margin;
+ requestLayout();
+ }
+
+ /**
+ * @return the top expanded title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginTop
+ * @see #setExpandedTitleMarginTop(int)
+ */
+ public int getExpandedTitleMarginTop() {
+ return expandedMarginTop;
+ }
+
+ /**
+ * Sets the top expanded title margin in pixels.
+ *
+ * @param margin the top title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginTop
+ * @see #getExpandedTitleMarginTop()
+ */
+ public void setExpandedTitleMarginTop(int margin) {
+ expandedMarginTop = margin;
+ requestLayout();
+ }
+
+ /**
+ * @return the ending expanded title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd
+ * @see #setExpandedTitleMarginEnd(int)
+ */
+ public int getExpandedTitleMarginEnd() {
+ return expandedMarginEnd;
+ }
+
+ /**
+ * Sets the ending expanded title margin in pixels.
+ *
+ * @param margin the ending title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd
+ * @see #getExpandedTitleMarginEnd()
+ */
+ public void setExpandedTitleMarginEnd(int margin) {
+ expandedMarginEnd = margin;
+ requestLayout();
+ }
+
+ /**
+ * @return the bottom expanded title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom
+ * @see #setExpandedTitleMarginBottom(int)
+ */
+ public int getExpandedTitleMarginBottom() {
+ return expandedMarginBottom;
+ }
+
+ /**
+ * Sets the bottom expanded title margin in pixels.
+ *
+ * @param margin the bottom title margin in pixels
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginBottom
+ * @see #getExpandedTitleMarginBottom()
+ */
+ public void setExpandedTitleMarginBottom(int margin) {
+ expandedMarginBottom = margin;
+ requestLayout();
+ }
+
+ /**
+ * Set the amount of visible height in pixels used to define when to trigger a scrim visibility
+ * change.
+ *
+ *
If the visible height of this view is less than the given value, the scrims will be made
+ * visible, otherwise they are hidden.
+ *
+ * @param height value in pixels used to define when to trigger a scrim visibility change
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_expandedTitleMarginEnd
+ */
+ public void setScrimVisibleHeightTrigger(@IntRange(from = 0) final int height) {
+ if (scrimVisibleHeightTrigger != height) {
+ scrimVisibleHeightTrigger = height;
+ // Update the scrim visibility
+ updateScrimVisibility();
+ }
+ }
+
+ /**
+ * Returns the amount of visible height in pixels used to define when to trigger a scrim
+ * visibility change.
+ *
+ * @see #setScrimVisibleHeightTrigger(int)
+ */
+ public int getScrimVisibleHeightTrigger() {
+ if (scrimVisibleHeightTrigger >= 0) {
+ // If we have one explicitly set, return it
+ return scrimVisibleHeightTrigger;
+ }
+
+ // Otherwise we'll use the default computed value
+ final int insetTop = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0;
+
+ final int minHeight = ViewCompat.getMinimumHeight(this);
+ if (minHeight > 0) {
+ // If we have a minHeight set, lets use 2 * minHeight (capped at our height)
+ return Math.min((minHeight * 2) + insetTop, getHeight());
+ }
+
+ // If we reach here then we don't have a min height set. Instead we'll take a
+ // guess at 1/3 of our height being visible
+ return getHeight() / 3;
+ }
+
+ /**
+ * Set the duration used for scrim visibility animations.
+ *
+ * @param duration the duration to use in milliseconds
+ * @attr ref com.google.android.material.R.styleable#SubtitleCollapsingToolbarLayout_scrimAnimationDuration
+ */
+ public void setScrimAnimationDuration(@IntRange(from = 0) final long duration) {
+ scrimAnimationDuration = duration;
+ }
+
+ /**
+ * Returns the duration in milliseconds used for scrim visibility animations.
+ */
+ public long getScrimAnimationDuration() {
+ return scrimAnimationDuration;
+ }
+
+ @Override
+ protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
+ return p instanceof LayoutParams;
+ }
+
+ @Override
+ protected LayoutParams generateDefaultLayoutParams() {
+ return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
+ }
+
+ @Override
+ public FrameLayout.LayoutParams generateLayoutParams(AttributeSet attrs) {
+ return new LayoutParams(getContext(), attrs);
+ }
+
+ @Override
+ protected FrameLayout.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
+ return new LayoutParams(p);
+ }
+
+ public static class LayoutParams extends CollapsingToolbarLayout.LayoutParams {
+ public LayoutParams(Context c, AttributeSet attrs) {
+ super(c, attrs);
+ }
+
+ public LayoutParams(int width, int height) {
+ super(width, height);
+ }
+
+ public LayoutParams(int width, int height, int gravity) {
+ super(width, height, gravity);
+ }
+
+ public LayoutParams(ViewGroup.LayoutParams p) {
+ super(p);
+ }
+
+ public LayoutParams(MarginLayoutParams source) {
+ super(source);
+ }
+
+ @RequiresApi(19)
+ public LayoutParams(FrameLayout.LayoutParams source) {
+ super(source);
+ }
+ }
+
+ /**
+ * Show or hide the scrims if needed
+ */
+ final void updateScrimVisibility() {
+ if (contentScrim != null || statusBarScrim != null) {
+ setScrimsShown(getHeight() + currentOffset < getScrimVisibleHeightTrigger());
+ }
+ }
+
+ final int getMaxOffsetForPinChild(View child) {
+ final ViewOffsetHelper offsetHelper = getViewOffsetHelper(child);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ return getHeight() - offsetHelper.getLayoutTop() - child.getHeight() - lp.bottomMargin;
+ }
+
+ private void updateContentDescriptionFromTitle() {
+ // Set this layout's contentDescription to match the title if it's shown by CollapsingTextHelper
+ setContentDescription(getTitle());
+ }
+
+ private class OffsetUpdateListener implements AppBarLayout.OnOffsetChangedListener {
+ OffsetUpdateListener() {
+ }
+
+ @Override
+ public void onOffsetChanged(AppBarLayout layout, int verticalOffset) {
+ currentOffset = verticalOffset;
+
+ final int insetTop = lastInsets != null ? lastInsets.getSystemWindowInsetTop() : 0;
+
+ for (int i = 0, z = getChildCount(); i < z; i++) {
+ final View child = getChildAt(i);
+ final LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ final ViewOffsetHelper offsetHelper = getViewOffsetHelper(child);
+
+ switch (lp.collapseMode) {
+ case LayoutParams.COLLAPSE_MODE_PIN:
+ offsetHelper.setTopAndBottomOffset(
+ MathUtils.clamp(-verticalOffset, 0, getMaxOffsetForPinChild(child)));
+ break;
+ case LayoutParams.COLLAPSE_MODE_PARALLAX:
+ offsetHelper.setTopAndBottomOffset(Math.round(-verticalOffset * lp.parallaxMult));
+ break;
+ default:
+ break;
+ }
+ }
+
+ // Show or hide the scrims if needed
+ updateScrimVisibility();
+
+ if (statusBarScrim != null && insetTop > 0) {
+ ViewCompat.postInvalidateOnAnimation(SubtitleCollapsingToolbarLayout.this);
+ }
+
+ // Update the collapsing text's fraction
+ final int expandRange = getHeight()
+ - ViewCompat.getMinimumHeight(SubtitleCollapsingToolbarLayout.this)
+ - insetTop;
+ collapsingTextHelper.setExpansionFraction(Math.abs(verticalOffset) / (float) expandRange);
+ }
+ }
+}
diff --git a/app/src/main/java/com/google/android/material/internal/SubtitleCollapsingTextHelper.java b/app/src/main/java/com/google/android/material/internal/SubtitleCollapsingTextHelper.java
new file mode 100644
index 00000000..d9a156e7
--- /dev/null
+++ b/app/src/main/java/com/google/android/material/internal/SubtitleCollapsingTextHelper.java
@@ -0,0 +1,1235 @@
+package com.google.android.material.internal;
+
+import android.animation.TimeInterpolator;
+import android.content.res.ColorStateList;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.os.Build;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.view.Gravity;
+import android.view.View;
+
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.math.MathUtils;
+import androidx.core.text.TextDirectionHeuristicsCompat;
+import androidx.core.view.GravityCompat;
+import androidx.core.view.ViewCompat;
+
+import com.google.android.material.animation.AnimationUtils;
+import com.google.android.material.resources.CancelableFontCallback;
+import com.google.android.material.resources.TextAppearance;
+
+/**
+ * Helper class for {@link com.google.android.material.appbar.SubtitleCollapsingToolbarLayout}.
+ *
+ * @see CollapsingTextHelper
+ */
+public final class SubtitleCollapsingTextHelper {
+
+ // Pre-JB-MR2 doesn't support HW accelerated canvas scaled title so we will workaround it
+ // by using our own texture
+ private static final boolean USE_SCALING_TEXTURE = Build.VERSION.SDK_INT < 18;
+
+ private static final boolean DEBUG_DRAW = false;
+ @NonNull
+ private static final Paint DEBUG_DRAW_PAINT;
+
+ static {
+ DEBUG_DRAW_PAINT = DEBUG_DRAW ? new Paint() : null;
+ if (DEBUG_DRAW_PAINT != null) {
+ DEBUG_DRAW_PAINT.setAntiAlias(true);
+ DEBUG_DRAW_PAINT.setColor(Color.MAGENTA);
+ }
+ }
+
+ private final View view;
+
+ private boolean drawTitle;
+ private float expandedFraction;
+
+ @NonNull
+ private final Rect expandedBounds;
+ @NonNull
+ private final Rect collapsedBounds;
+ @NonNull
+ private final RectF currentBounds;
+ private int expandedTextGravity = Gravity.CENTER_VERTICAL;
+ private int collapsedTextGravity = Gravity.CENTER_VERTICAL;
+ private float expandedTitleTextSize, expandedSubtitleTextSize = 15;
+ private float collapsedTitleTextSize, collapsedSubtitleTextSize = 15;
+ private ColorStateList expandedTitleTextColor, expandedSubtitleTextColor;
+ private ColorStateList collapsedTitleTextColor, collapsedSubtitleTextColor;
+
+ private float expandedTitleDrawY, expandedSubtitleDrawY;
+ private float collapsedTitleDrawY, collapsedSubtitleDrawY;
+ private float expandedTitleDrawX, expandedSubtitleDrawX;
+ private float collapsedTitleDrawX, collapsedSubtitleDrawX;
+ private float currentTitleDrawX, currentSubtitleDrawX;
+ private float currentTitleDrawY, currentSubtitleDrawY;
+ private Typeface collapsedTitleTypeface, collapsedSubtitleTypeface;
+ private Typeface expandedTitleTypeface, expandedSubtitleTypeface;
+ private Typeface currentTitleTypeface, currentSubtitleTypeface;
+ private CancelableFontCallback expandedTitleFontCallback, expandedSubtitleFontCallback;
+ private CancelableFontCallback collapsedTitleFontCallback, collapsedSubtitleFontCallback;
+
+ @Nullable
+ private CharSequence title, subtitle;
+ @Nullable
+ private CharSequence titleToDraw, subtitleToDraw;
+ private boolean isRtl;
+
+ private boolean useTexture;
+ @Nullable
+ private Bitmap expandedTitleTexture, expandedSubtitleTexture;
+ private Paint titleTexturePaint, subtitleTexturePaint;
+ private float titleTextureAscent, subtitleTextureAscent;
+ private float titleTextureDescent, subtitleTextureDescent;
+
+ private float titleScale, subtitleScale;
+ private float currentTitleTextSize, currentSubtitleTextSize;
+
+ private int[] state;
+
+ private boolean boundsChanged;
+
+ @NonNull
+ private final TextPaint titleTextPaint, subtitleTextPaint;
+ @NonNull
+ private final TextPaint titleTmpPaint, subtitleTmpPaint;
+
+ private TimeInterpolator positionInterpolator;
+ private TimeInterpolator textSizeInterpolator;
+
+ private float collapsedTitleShadowRadius, collapsedSubtitleShadowRadius;
+ private float collapsedTitleShadowDx, collapsedSubtitleShadowDx;
+ private float collapsedTitleShadowDy, collapsedSubtitleShadowDy;
+ private ColorStateList collapsedTitleShadowColor, collapsedSubtitleShadowColor;
+
+ private float expandedTitleShadowRadius, expandedSubtitleShadowRadius;
+ private float expandedTitleShadowDx, expandedSubtitleShadowDx;
+ private float expandedTitleShadowDy, expandedSubtitleShadowDy;
+ private ColorStateList expandedTitleShadowColor, expandedSubtitleShadowColor;
+
+ public SubtitleCollapsingTextHelper(View view) {
+ this.view = view;
+
+ titleTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.SUBPIXEL_TEXT_FLAG);
+ titleTmpPaint = new TextPaint(titleTextPaint);
+ subtitleTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.SUBPIXEL_TEXT_FLAG);
+ subtitleTmpPaint = new TextPaint(subtitleTextPaint);
+
+ collapsedBounds = new Rect();
+ expandedBounds = new Rect();
+ currentBounds = new RectF();
+ }
+
+
+ public void setTextSizeInterpolator(TimeInterpolator interpolator) {
+ textSizeInterpolator = interpolator;
+ recalculate();
+ }
+
+ public void setPositionInterpolator(TimeInterpolator interpolator) {
+ positionInterpolator = interpolator;
+ recalculate();
+ }
+
+ public void setExpandedTitleTextSize(float textSize) {
+ if (expandedTitleTextSize != textSize) {
+ expandedTitleTextSize = textSize;
+ recalculate();
+ }
+ }
+
+ public void setCollapsedTitleTextSize(float textSize) {
+ if (collapsedTitleTextSize != textSize) {
+ collapsedTitleTextSize = textSize;
+ recalculate();
+ }
+ }
+
+ public void setExpandedSubtitleTextSize(float textSize) {
+ if (expandedSubtitleTextSize != textSize) {
+ expandedSubtitleTextSize = textSize;
+ recalculate();
+ }
+ }
+
+ public void setCollapsedSubtitleTextSize(float textSize) {
+ if (collapsedSubtitleTextSize != textSize) {
+ collapsedSubtitleTextSize = textSize;
+ recalculate();
+ }
+ }
+
+ public void setCollapsedTitleTextColor(ColorStateList textColor) {
+ if (collapsedTitleTextColor != textColor) {
+ collapsedTitleTextColor = textColor;
+ recalculate();
+ }
+ }
+
+ public void setExpandedTitleTextColor(ColorStateList textColor) {
+ if (expandedTitleTextColor != textColor) {
+ expandedTitleTextColor = textColor;
+ recalculate();
+ }
+ }
+
+ public void setCollapsedSubtitleTextColor(ColorStateList textColor) {
+ if (collapsedSubtitleTextColor != textColor) {
+ collapsedSubtitleTextColor = textColor;
+ recalculate();
+ }
+ }
+
+ public void setExpandedSubtitleTextColor(ColorStateList textColor) {
+ if (expandedSubtitleTextColor != textColor) {
+ expandedSubtitleTextColor = textColor;
+ recalculate();
+ }
+ }
+
+ public void setExpandedBounds(int left, int top, int right, int bottom) {
+ if (!rectEquals(expandedBounds, left, top, right, bottom)) {
+ expandedBounds.set(left, top, right, bottom);
+ boundsChanged = true;
+ onBoundsChanged();
+ }
+ }
+
+ public void setExpandedBounds(@NonNull Rect bounds) {
+ setExpandedBounds(bounds.left, bounds.top, bounds.right, bounds.bottom);
+ }
+
+ public void setCollapsedBounds(int left, int top, int right, int bottom) {
+ if (!rectEquals(collapsedBounds, left, top, right, bottom)) {
+ collapsedBounds.set(left, top, right, bottom);
+ boundsChanged = true;
+ onBoundsChanged();
+ }
+ }
+
+ public void setCollapsedBounds(@NonNull Rect bounds) {
+ setCollapsedBounds(bounds.left, bounds.top, bounds.right, bounds.bottom);
+ }
+
+ public void getCollapsedTitleTextActualBounds(@NonNull RectF bounds) {
+ boolean isRtl = calculateIsRtl(title);
+
+ bounds.left = !isRtl ? collapsedBounds.left : collapsedBounds.right - calculateCollapsedTitleTextWidth();
+ bounds.top = collapsedBounds.top;
+ bounds.right = !isRtl ? bounds.left + calculateCollapsedTitleTextWidth() : collapsedBounds.right;
+ bounds.bottom = collapsedBounds.top + getCollapsedTitleTextHeight();
+ }
+
+ public float calculateCollapsedTitleTextWidth() {
+ if (title == null) {
+ return 0;
+ }
+ getTitleTextPaintCollapsed(titleTmpPaint);
+ return titleTmpPaint.measureText(title, 0, title.length());
+ }
+
+ public void getCollapsedSubtitleTextActualBounds(@NonNull RectF bounds) {
+ boolean isRtl = calculateIsRtl(subtitle);
+
+ bounds.left = !isRtl ? collapsedBounds.left : collapsedBounds.right - calculateCollapsedSubtitleTextWidth();
+ bounds.top = collapsedBounds.top;
+ bounds.right = !isRtl ? bounds.left + calculateCollapsedSubtitleTextWidth() : collapsedBounds.right;
+ bounds.bottom = collapsedBounds.top + getCollapsedSubtitleTextHeight();
+ }
+
+ public float calculateCollapsedSubtitleTextWidth() {
+ if (subtitle == null) {
+ return 0;
+ }
+ getSubtitleTextPaintCollapsed(subtitleTmpPaint);
+ return subtitleTmpPaint.measureText(subtitle, 0, subtitle.length());
+ }
+
+ public float getExpandedTitleTextHeight() {
+ getTitleTextPaintExpanded(titleTmpPaint);
+ // Return expanded height measured from the baseline.
+ return -titleTmpPaint.ascent();
+ }
+
+ public float getCollapsedTitleTextHeight() {
+ getTitleTextPaintCollapsed(titleTmpPaint);
+ // Return collapsed height measured from the baseline.
+ return -titleTmpPaint.ascent();
+ }
+
+ public float getExpandedSubtitleTextHeight() {
+ getSubtitleTextPaintExpanded(subtitleTmpPaint);
+ // Return expanded height measured from the baseline.
+ return -subtitleTmpPaint.ascent();
+ }
+
+ public float getCollapsedSubtitleTextHeight() {
+ getSubtitleTextPaintCollapsed(subtitleTmpPaint);
+ // Return collapsed height measured from the baseline.
+ return -subtitleTmpPaint.ascent();
+ }
+
+ private void getTitleTextPaintExpanded(@NonNull TextPaint textPaint) {
+ textPaint.setTextSize(expandedTitleTextSize);
+ textPaint.setTypeface(expandedTitleTypeface);
+ }
+
+ private void getTitleTextPaintCollapsed(@NonNull TextPaint textPaint) {
+ textPaint.setTextSize(collapsedTitleTextSize);
+ textPaint.setTypeface(collapsedTitleTypeface);
+ }
+
+ private void getSubtitleTextPaintExpanded(@NonNull TextPaint textPaint) {
+ textPaint.setTextSize(expandedSubtitleTextSize);
+ textPaint.setTypeface(expandedSubtitleTypeface);
+ }
+
+ private void getSubtitleTextPaintCollapsed(@NonNull TextPaint textPaint) {
+ textPaint.setTextSize(collapsedSubtitleTextSize);
+ textPaint.setTypeface(collapsedSubtitleTypeface);
+ }
+
+ void onBoundsChanged() {
+ drawTitle = collapsedBounds.width() > 0
+ && collapsedBounds.height() > 0
+ && expandedBounds.width() > 0
+ && expandedBounds.height() > 0;
+ }
+
+ public void setExpandedTextGravity(int gravity) {
+ if (expandedTextGravity != gravity) {
+ expandedTextGravity = gravity;
+ recalculate();
+ }
+ }
+
+ public int getExpandedTextGravity() {
+ return expandedTextGravity;
+ }
+
+ public void setCollapsedTextGravity(int gravity) {
+ if (collapsedTextGravity != gravity) {
+ collapsedTextGravity = gravity;
+ recalculate();
+ }
+ }
+
+ public int getCollapsedTextGravity() {
+ return collapsedTextGravity;
+ }
+
+ public void setCollapsedTitleTextAppearance(int resId) {
+ TextAppearance textAppearance = new TextAppearance(view.getContext(), resId);
+
+ if (textAppearance.getTextColor() != null) {
+ collapsedTitleTextColor = textAppearance.getTextColor();
+ }
+ if (textAppearance.getTextSize() != 0) {
+ collapsedTitleTextSize = textAppearance.getTextSize();
+ }
+ if (textAppearance.shadowColor != null) {
+ collapsedTitleShadowColor = textAppearance.shadowColor;
+ }
+ collapsedTitleShadowDx = textAppearance.shadowDx;
+ collapsedTitleShadowDy = textAppearance.shadowDy;
+ collapsedTitleShadowRadius = textAppearance.shadowRadius;
+
+ // Cancel pending async fetch, if any, and replace with a new one.
+ if (collapsedTitleFontCallback != null) {
+ collapsedTitleFontCallback.cancel();
+ }
+ collapsedTitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() {
+ @Override
+ public void apply(Typeface font) {
+ setCollapsedTitleTypeface(font);
+ }
+ }, textAppearance.getFallbackFont());
+ textAppearance.getFontAsync(view.getContext(), collapsedTitleFontCallback);
+
+ recalculate();
+ }
+
+ public void setExpandedTitleTextAppearance(int resId) {
+ TextAppearance textAppearance = new TextAppearance(view.getContext(), resId);
+ if (textAppearance.getTextColor() != null) {
+ expandedTitleTextColor = textAppearance.getTextColor();
+ }
+ if (textAppearance.getTextSize() != 0) {
+ expandedTitleTextSize = textAppearance.getTextSize();
+ }
+ if (textAppearance.shadowColor != null) {
+ expandedTitleShadowColor = textAppearance.shadowColor;
+ }
+ expandedTitleShadowDx = textAppearance.shadowDx;
+ expandedTitleShadowDy = textAppearance.shadowDy;
+ expandedTitleShadowRadius = textAppearance.shadowRadius;
+
+ // Cancel pending async fetch, if any, and replace with a new one.
+ if (expandedTitleFontCallback != null) {
+ expandedTitleFontCallback.cancel();
+ }
+ expandedTitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() {
+ @Override
+ public void apply(Typeface font) {
+ setExpandedTitleTypeface(font);
+ }
+ }, textAppearance.getFallbackFont());
+ textAppearance.getFontAsync(view.getContext(), expandedTitleFontCallback);
+
+ recalculate();
+ }
+
+ public void setCollapsedSubtitleTextAppearance(int resId) {
+ TextAppearance textAppearance = new TextAppearance(view.getContext(), resId);
+
+ if (textAppearance.getTextColor() != null) {
+ collapsedSubtitleTextColor = textAppearance.getTextColor();
+ }
+ if (textAppearance.getTextSize() != 0) {
+ collapsedSubtitleTextSize = textAppearance.getTextSize();
+ }
+ if (textAppearance.shadowColor != null) {
+ collapsedSubtitleShadowColor = textAppearance.shadowColor;
+ }
+ collapsedSubtitleShadowDx = textAppearance.shadowDx;
+ collapsedSubtitleShadowDy = textAppearance.shadowDy;
+ collapsedSubtitleShadowRadius = textAppearance.shadowRadius;
+
+ // Cancel pending async fetch, if any, and replace with a new one.
+ if (collapsedSubtitleFontCallback != null) {
+ collapsedSubtitleFontCallback.cancel();
+ }
+ collapsedSubtitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() {
+ @Override
+ public void apply(Typeface font) {
+ setCollapsedSubtitleTypeface(font);
+ }
+ }, textAppearance.getFallbackFont());
+ textAppearance.getFontAsync(view.getContext(), collapsedSubtitleFontCallback);
+
+ recalculate();
+ }
+
+ public void setExpandedSubtitleTextAppearance(int resId) {
+ TextAppearance textAppearance = new TextAppearance(view.getContext(), resId);
+ if (textAppearance.getTextColor() != null) {
+ expandedSubtitleTextColor = textAppearance.getTextColor();
+ }
+ if (textAppearance.getTextSize() != 0) {
+ expandedSubtitleTextSize = textAppearance.getTextSize();
+ }
+ if (textAppearance.shadowColor != null) {
+ expandedSubtitleShadowColor = textAppearance.shadowColor;
+ }
+ expandedSubtitleShadowDx = textAppearance.shadowDx;
+ expandedSubtitleShadowDy = textAppearance.shadowDy;
+ expandedSubtitleShadowRadius = textAppearance.shadowRadius;
+
+ // Cancel pending async fetch, if any, and replace with a new one.
+ if (expandedSubtitleFontCallback != null) {
+ expandedSubtitleFontCallback.cancel();
+ }
+ expandedSubtitleFontCallback = new CancelableFontCallback(new CancelableFontCallback.ApplyFont() {
+ @Override
+ public void apply(Typeface font) {
+ if (font != null) setExpandedSubtitleTypeface(font);
+ }
+ }, null);
+ textAppearance.getFontAsync(view.getContext(), expandedSubtitleFontCallback);
+
+ recalculate();
+ }
+
+ public void setCollapsedTitleTypeface(Typeface typeface) {
+ if (setCollapsedTitleTypefaceInternal(typeface)) {
+ recalculate();
+ }
+ }
+
+ public void setExpandedTitleTypeface(Typeface typeface) {
+ if (setExpandedTitleTypefaceInternal(typeface)) {
+ recalculate();
+ }
+ }
+
+ public void setCollapsedSubtitleTypeface(Typeface typeface) {
+ if (setCollapsedSubtitleTypefaceInternal(typeface)) {
+ recalculate();
+ }
+ }
+
+ public void setExpandedSubtitleTypeface(Typeface typeface) {
+ if (setExpandedSubtitleTypefaceInternal(typeface)) {
+ recalculate();
+ }
+ }
+
+ public void setTitleTypefaces(Typeface typeface) {
+ boolean collapsedFontChanged = setCollapsedTitleTypefaceInternal(typeface);
+ boolean expandedFontChanged = setExpandedTitleTypefaceInternal(typeface);
+ if (collapsedFontChanged || expandedFontChanged) {
+ recalculate();
+ }
+ }
+
+ public void setSubtitleTypefaces(Typeface typeface) {
+ boolean collapsedFontChanged = setCollapsedSubtitleTypefaceInternal(typeface);
+ boolean expandedFontChanged = setExpandedSubtitleTypefaceInternal(typeface);
+ if (collapsedFontChanged || expandedFontChanged) {
+ recalculate();
+ }
+ }
+
+ @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView
+ private boolean setCollapsedTitleTypefaceInternal(Typeface typeface) {
+ // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding
+ // already updated one when async op comes back after a while.
+ if (collapsedTitleFontCallback != null) {
+ collapsedTitleFontCallback.cancel();
+ }
+ if (collapsedTitleTypeface != typeface) {
+ collapsedTitleTypeface = typeface;
+ return true;
+ }
+ return false;
+ }
+
+ @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView
+ private boolean setExpandedTitleTypefaceInternal(Typeface typeface) {
+ // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding
+ // already updated one when async op comes back after a while.
+ if (expandedTitleFontCallback != null) {
+ expandedTitleFontCallback.cancel();
+ }
+ if (expandedTitleTypeface != typeface) {
+ expandedTitleTypeface = typeface;
+ return true;
+ }
+ return false;
+ }
+
+ @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView
+ private boolean setCollapsedSubtitleTypefaceInternal(Typeface typeface) {
+ // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding
+ // already updated one when async op comes back after a while.
+ if (collapsedSubtitleFontCallback != null) {
+ collapsedSubtitleFontCallback.cancel();
+ }
+ if (collapsedSubtitleTypeface != typeface) {
+ collapsedSubtitleTypeface = typeface;
+ return true;
+ }
+ return false;
+ }
+
+ @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView
+ private boolean setExpandedSubtitleTypefaceInternal(Typeface typeface) {
+ // Explicit Typeface setting cancels pending async fetch, if any, to avoid old font overriding
+ // already updated one when async op comes back after a while.
+ if (expandedSubtitleFontCallback != null) {
+ expandedSubtitleFontCallback.cancel();
+ }
+ if (expandedSubtitleTypeface != typeface) {
+ expandedSubtitleTypeface = typeface;
+ return true;
+ }
+ return false;
+ }
+
+ public Typeface getCollapsedTitleTypeface() {
+ return collapsedTitleTypeface != null ? collapsedTitleTypeface : Typeface.DEFAULT;
+ }
+
+ public Typeface getExpandedTitleTypeface() {
+ return expandedTitleTypeface != null ? expandedTitleTypeface : Typeface.DEFAULT;
+ }
+
+ public Typeface getCollapsedSubtitleTypeface() {
+ return collapsedSubtitleTypeface != null ? collapsedSubtitleTypeface : Typeface.DEFAULT;
+ }
+
+ public Typeface getExpandedSubtitleTypeface() {
+ return expandedSubtitleTypeface != null ? expandedSubtitleTypeface : Typeface.DEFAULT;
+ }
+
+ /**
+ * Set the value indicating the current scroll value. This decides how much of the background will
+ * be displayed, as well as the title metrics/positioning.
+ *
+ *
A value of {@code 0.0} indicates that the layout is fully expanded. A value of {@code 1.0}
+ * indicates that the layout is fully collapsed.
+ */
+ public void setExpansionFraction(float fraction) {
+ fraction = MathUtils.clamp(fraction, 0f, 1f);
+
+ if (fraction != expandedFraction) {
+ expandedFraction = fraction;
+ calculateCurrentOffsets();
+ }
+ }
+
+ public final boolean setState(final int[] state) {
+ this.state = state;
+
+ if (isStateful()) {
+ recalculate();
+ return true;
+ }
+
+ return false;
+ }
+
+ public final boolean isStateful() {
+ return (collapsedTitleTextColor != null && collapsedTitleTextColor.isStateful())
+ || (expandedTitleTextColor != null && expandedTitleTextColor.isStateful());
+ }
+
+ public float getExpansionFraction() {
+ return expandedFraction;
+ }
+
+ public float getCollapsedTitleTextSize() {
+ return collapsedTitleTextSize;
+ }
+
+ public float getExpandedTitleTextSize() {
+ return expandedTitleTextSize;
+ }
+
+ public float getCollapsedSubtitleTextSize() {
+ return collapsedSubtitleTextSize;
+ }
+
+ public float getExpandedSubtitleTextSize() {
+ return expandedSubtitleTextSize;
+ }
+
+ private void calculateCurrentOffsets() {
+ calculateOffsets(expandedFraction);
+ }
+
+ private void calculateOffsets(final float fraction) {
+ interpolateBounds(fraction);
+ currentTitleDrawX = lerp(expandedTitleDrawX, collapsedTitleDrawX, fraction, positionInterpolator);
+ currentTitleDrawY = lerp(expandedTitleDrawY, collapsedTitleDrawY, fraction, positionInterpolator);
+ currentSubtitleDrawX = lerp(expandedSubtitleDrawX, collapsedSubtitleDrawX, fraction, positionInterpolator);
+ currentSubtitleDrawY = lerp(expandedSubtitleDrawY, collapsedSubtitleDrawY, fraction, positionInterpolator);
+
+ setInterpolatedTitleTextSize(lerp(expandedTitleTextSize, collapsedTitleTextSize, fraction, textSizeInterpolator));
+ setInterpolatedSubtitleTextSize(lerp(expandedSubtitleTextSize, collapsedSubtitleTextSize, fraction, textSizeInterpolator));
+
+ if (collapsedTitleTextColor != expandedTitleTextColor) {
+ // If the collapsed and expanded title colors are different, blend them based on the
+ // fraction
+ titleTextPaint.setColor(blendColors(getCurrentExpandedTitleTextColor(), getCurrentCollapsedTitleTextColor(), fraction));
+ } else {
+ titleTextPaint.setColor(getCurrentCollapsedTitleTextColor());
+ }
+
+ titleTextPaint.setShadowLayer(
+ lerp(expandedTitleShadowRadius, collapsedTitleShadowRadius, fraction, null),
+ lerp(expandedTitleShadowDx, collapsedTitleShadowDx, fraction, null),
+ lerp(expandedTitleShadowDy, collapsedTitleShadowDy, fraction, null),
+ blendColors(getCurrentColor(expandedTitleShadowColor), getCurrentColor(collapsedTitleShadowColor), fraction));
+
+ if (collapsedSubtitleTextColor != expandedSubtitleTextColor) {
+ // If the collapsed and expanded title colors are different, blend them based on the
+ // fraction
+ subtitleTextPaint.setColor(blendColors(getCurrentExpandedSubtitleTextColor(), getCurrentCollapsedSubtitleTextColor(), fraction));
+ } else {
+ subtitleTextPaint.setColor(getCurrentCollapsedSubtitleTextColor());
+ }
+
+ subtitleTextPaint.setShadowLayer(
+ lerp(expandedSubtitleShadowRadius, collapsedSubtitleShadowRadius, fraction, null),
+ lerp(expandedSubtitleShadowDx, collapsedSubtitleShadowDx, fraction, null),
+ lerp(expandedSubtitleShadowDy, collapsedSubtitleShadowDy, fraction, null),
+ blendColors(getCurrentColor(expandedSubtitleShadowColor), getCurrentColor(collapsedSubtitleShadowColor), fraction));
+
+ ViewCompat.postInvalidateOnAnimation(view);
+ }
+
+ @ColorInt
+ private int getCurrentExpandedTitleTextColor() {
+ return getCurrentColor(expandedTitleTextColor);
+ }
+
+ @ColorInt
+ private int getCurrentExpandedSubtitleTextColor() {
+ return getCurrentColor(expandedSubtitleTextColor);
+ }
+
+ @ColorInt
+ public int getCurrentCollapsedTitleTextColor() {
+ return getCurrentColor(collapsedTitleTextColor);
+ }
+
+ @ColorInt
+ public int getCurrentCollapsedSubtitleTextColor() {
+ return getCurrentColor(collapsedSubtitleTextColor);
+ }
+
+ @ColorInt
+ private int getCurrentColor(@Nullable ColorStateList colorStateList) {
+ if (colorStateList == null) {
+ return 0;
+ }
+ if (state != null) {
+ return colorStateList.getColorForState(state, 0);
+ }
+ return colorStateList.getDefaultColor();
+ }
+
+ private void calculateBaseOffsets() {
+ final float currentTitleSize = this.currentTitleTextSize;
+ final float currentSubtitleSize = this.currentSubtitleTextSize;
+ final boolean isTitleOnly = TextUtils.isEmpty(subtitle);
+
+ // We then calculate the collapsed title size, using the same logic
+ calculateUsingTitleTextSize(collapsedTitleTextSize);
+ calculateUsingSubtitleTextSize(collapsedSubtitleTextSize);
+ float titleWidth = titleToDraw != null ? titleTextPaint.measureText(titleToDraw, 0, titleToDraw.length()) : 0;
+ float subtitleWidth = subtitleToDraw != null ? subtitleTextPaint.measureText(subtitleToDraw, 0, subtitleToDraw.length()) : 0;
+ final int collapsedAbsGravity =
+ GravityCompat.getAbsoluteGravity(
+ collapsedTextGravity,
+ isRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR);
+
+ // reusable dimension
+ float titleHeight = titleTextPaint.descent() - titleTextPaint.ascent();
+ float titleOffset = titleHeight / 2 - titleTextPaint.descent();
+ float subtitleHeight = subtitleTextPaint.descent() - subtitleTextPaint.ascent();
+ float subtitleOffset = subtitleHeight / 2 - subtitleTextPaint.descent();
+
+ if (isTitleOnly) {
+ switch (collapsedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) {
+ case Gravity.BOTTOM:
+ collapsedTitleDrawY = collapsedBounds.bottom;
+ break;
+ case Gravity.TOP:
+ collapsedTitleDrawY = collapsedBounds.top - titleTextPaint.ascent();
+ break;
+ case Gravity.CENTER_VERTICAL:
+ default:
+ float textHeight = titleTextPaint.descent() - titleTextPaint.ascent();
+ float textOffset = (textHeight / 2) - titleTextPaint.descent();
+ collapsedTitleDrawY = collapsedBounds.centerY() + textOffset;
+ break;
+ }
+ } else {
+ final float offset = (collapsedBounds.height() - (titleHeight + subtitleHeight)) / 3;
+ collapsedTitleDrawY = collapsedBounds.top + offset - titleTextPaint.ascent();
+ collapsedSubtitleDrawY = collapsedBounds.top + offset * 2 + titleHeight - subtitleTextPaint.ascent();
+ }
+ switch (collapsedAbsGravity & GravityCompat.RELATIVE_HORIZONTAL_GRAVITY_MASK) {
+ case Gravity.CENTER_HORIZONTAL:
+ collapsedTitleDrawX = collapsedBounds.centerX() - (titleWidth / 2);
+ collapsedSubtitleDrawX = collapsedBounds.centerX() - (subtitleWidth / 2);
+ break;
+ case Gravity.RIGHT:
+ collapsedTitleDrawX = collapsedBounds.right - titleWidth;
+ collapsedSubtitleDrawX = collapsedBounds.right - subtitleWidth;
+ break;
+ case Gravity.LEFT:
+ default:
+ collapsedTitleDrawX = collapsedBounds.left;
+ collapsedSubtitleDrawX = collapsedBounds.left;
+ break;
+ }
+
+ calculateUsingTitleTextSize(expandedTitleTextSize);
+ calculateUsingSubtitleTextSize(expandedSubtitleTextSize);
+ titleWidth = titleToDraw != null ? titleTextPaint.measureText(titleToDraw, 0, titleToDraw.length()) : 0;
+ subtitleWidth = subtitleToDraw != null ? subtitleTextPaint.measureText(subtitleToDraw, 0, subtitleToDraw.length()) : 0;
+
+ // dimension modification
+ titleHeight = titleTextPaint.descent() - titleTextPaint.ascent();
+ titleOffset = titleHeight / 2 - titleTextPaint.descent();
+ subtitleHeight = subtitleTextPaint.descent() - subtitleTextPaint.ascent();
+ subtitleOffset = subtitleHeight / 2 - subtitleTextPaint.descent();
+
+ final int expandedAbsGravity = GravityCompat.getAbsoluteGravity(
+ expandedTextGravity,
+ isRtl ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR
+ );
+ if (isTitleOnly) {
+ switch (expandedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) {
+ case Gravity.BOTTOM:
+ expandedTitleDrawY = expandedBounds.bottom;
+ break;
+ case Gravity.TOP:
+ expandedTitleDrawY = expandedBounds.top - titleTextPaint.ascent();
+ break;
+ case Gravity.CENTER_VERTICAL:
+ default:
+ float textHeight = titleTextPaint.descent() - titleTextPaint.ascent();
+ float textOffset = (textHeight / 2) - titleTextPaint.descent();
+ expandedTitleDrawY = expandedBounds.centerY() + textOffset;
+ break;
+ }
+ } else {
+ switch (expandedAbsGravity & Gravity.VERTICAL_GRAVITY_MASK) {
+ case Gravity.BOTTOM:
+ expandedTitleDrawY = expandedBounds.bottom - subtitleHeight - titleOffset;
+ expandedSubtitleDrawY = expandedBounds.bottom;
+ break;
+ case Gravity.TOP:
+ expandedTitleDrawY = expandedBounds.top - titleTextPaint.ascent();
+ expandedSubtitleDrawY = expandedTitleDrawY + subtitleHeight + titleOffset;
+ break;
+ case Gravity.CENTER_VERTICAL:
+ default:
+ expandedTitleDrawY = expandedBounds.centerY() + titleOffset;
+ expandedSubtitleDrawY = expandedTitleDrawY + subtitleHeight + titleOffset;
+ break;
+ }
+ }
+ switch (expandedAbsGravity & GravityCompat.RELATIVE_HORIZONTAL_GRAVITY_MASK) {
+ case Gravity.CENTER_HORIZONTAL:
+ expandedTitleDrawX = expandedBounds.centerX() - (titleWidth / 2);
+ expandedSubtitleDrawX = expandedBounds.centerX() - (subtitleWidth / 2);
+ break;
+ case Gravity.RIGHT:
+ expandedTitleDrawX = expandedBounds.right - titleWidth;
+ expandedSubtitleDrawX = expandedBounds.right - subtitleWidth;
+ break;
+ case Gravity.LEFT:
+ default:
+ expandedTitleDrawX = expandedBounds.left;
+ expandedSubtitleDrawX = expandedBounds.left;
+ break;
+ }
+
+ // The bounds have changed so we need to clear the texture
+ clearTexture();
+ // Now reset the title size back to the original
+ setInterpolatedTitleTextSize(currentTitleSize);
+ setInterpolatedSubtitleTextSize(currentSubtitleSize);
+ }
+
+ private void interpolateBounds(float fraction) {
+ currentBounds.left = lerp(expandedBounds.left, collapsedBounds.left, fraction, positionInterpolator);
+ currentBounds.top = lerp(expandedTitleDrawY, collapsedTitleDrawY, fraction, positionInterpolator);
+ currentBounds.right = lerp(expandedBounds.right, collapsedBounds.right, fraction, positionInterpolator);
+ currentBounds.bottom = lerp(expandedBounds.bottom, collapsedBounds.bottom, fraction, positionInterpolator);
+ }
+
+ public void draw(@NonNull Canvas canvas) {
+ final int saveCount = canvas.save();
+
+ if (drawTitle && titleToDraw != null) {
+ float titleX = currentTitleDrawX;
+ float titleY = currentTitleDrawY;
+ float subtitleX = currentSubtitleDrawX;
+ float subtitleY = currentSubtitleDrawY;
+
+ final boolean drawTitleTexture = useTexture && expandedTitleTexture != null;
+ final boolean drawSubtitleTexture = useTexture && expandedSubtitleTexture != null;
+
+ final float titleAscent;
+ final float titleDescent;
+ if (drawTitleTexture) {
+ titleAscent = titleTextureAscent * titleScale;
+ titleDescent = titleTextureDescent * titleScale;
+ } else {
+ titleAscent = titleTextPaint.ascent() * titleScale;
+ titleDescent = titleTextPaint.descent() * titleScale;
+ }
+
+ if (DEBUG_DRAW) {
+ // Just a debug tool, which drawn a magenta rect in the text bounds
+ canvas.drawRect(currentBounds.left, titleY + titleAscent, currentBounds.right, titleY + titleDescent, DEBUG_DRAW_PAINT);
+ }
+
+ if (drawTitleTexture) {
+ titleY += titleAscent;
+ }
+
+ // additional canvas save for subtitle
+ if (subtitleToDraw != null) {
+ final int subtitleSaveCount = canvas.save();
+
+ if (subtitleScale != 1f) {
+ canvas.scale(subtitleScale, subtitleScale, subtitleX, subtitleY);
+ }
+
+ if (drawSubtitleTexture) {
+ // If we should use a texture, draw it instead of title
+ canvas.drawBitmap(expandedSubtitleTexture, subtitleX, subtitleY, subtitleTexturePaint);
+ } else {
+ canvas.drawText(subtitleToDraw, 0, subtitleToDraw.length(), subtitleX, subtitleY, subtitleTextPaint);
+ }
+ canvas.restoreToCount(subtitleSaveCount);
+ }
+
+ if (titleScale != 1f) {
+ canvas.scale(titleScale, titleScale, titleX, titleY);
+ }
+
+ if (drawTitleTexture) {
+ // If we should use a texture, draw it instead of text
+ canvas.drawBitmap(expandedTitleTexture, titleX, titleY, titleTexturePaint);
+ } else {
+ canvas.drawText(titleToDraw, 0, titleToDraw.length(), titleX, titleY, titleTextPaint);
+ }
+ }
+
+ canvas.restoreToCount(saveCount);
+ }
+
+ private boolean calculateIsRtl(@NonNull CharSequence text) {
+ final boolean defaultIsRtl = ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL;
+ return (defaultIsRtl
+ ? TextDirectionHeuristicsCompat.FIRSTSTRONG_RTL
+ : TextDirectionHeuristicsCompat.FIRSTSTRONG_LTR).isRtl(text, 0, text.length());
+ }
+
+ private void setInterpolatedTitleTextSize(float textSize) {
+ calculateUsingTitleTextSize(textSize);
+
+ // Use our texture if the scale isn't 1.0
+ useTexture = USE_SCALING_TEXTURE && titleScale != 1f;
+
+ if (useTexture) {
+ // Make sure we have an expanded texture if needed
+ ensureExpandedTitleTexture();
+ }
+
+ ViewCompat.postInvalidateOnAnimation(view);
+ }
+
+ private void setInterpolatedSubtitleTextSize(float textSize) {
+ calculateUsingSubtitleTextSize(textSize);
+
+ // Use our texture if the scale isn't 1.0
+ useTexture = USE_SCALING_TEXTURE && subtitleScale != 1f;
+
+ if (useTexture) {
+ // Make sure we have an expanded texture if needed
+ ensureExpandedSubtitleTexture();
+ }
+
+ ViewCompat.postInvalidateOnAnimation(view);
+ }
+
+ @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView
+ private void calculateUsingTitleTextSize(final float size) {
+ if (title == null) {
+ return;
+ }
+
+ final float collapsedWidth = collapsedBounds.width();
+ final float expandedWidth = expandedBounds.width();
+
+ final float availableWidth;
+ final float newTextSize;
+ boolean updateDrawText = false;
+
+ if (isClose(size, collapsedTitleTextSize)) {
+ newTextSize = collapsedTitleTextSize;
+ titleScale = 1f;
+ if (currentTitleTypeface != collapsedTitleTypeface) {
+ currentTitleTypeface = collapsedTitleTypeface;
+ updateDrawText = true;
+ }
+ availableWidth = collapsedWidth;
+ } else {
+ newTextSize = expandedTitleTextSize;
+ if (currentTitleTypeface != expandedTitleTypeface) {
+ currentTitleTypeface = expandedTitleTypeface;
+ updateDrawText = true;
+ }
+ if (isClose(size, expandedTitleTextSize)) {
+ // If we're close to the expanded title size, snap to it and use a scale of 1
+ titleScale = 1f;
+ } else {
+ // Else, we'll scale down from the expanded title size
+ titleScale = size / expandedTitleTextSize;
+ }
+
+ final float textSizeRatio = collapsedTitleTextSize / expandedTitleTextSize;
+ // This is the size of the expanded bounds when it is scaled to match the
+ // collapsed title size
+ final float scaledDownWidth = expandedWidth * textSizeRatio;
+
+ if (scaledDownWidth > collapsedWidth) {
+ // If the scaled down size is larger than the actual collapsed width, we need to
+ // cap the available width so that when the expanded title scales down, it matches
+ // the collapsed width
+ availableWidth = Math.min(collapsedWidth / textSizeRatio, expandedWidth);
+ } else {
+ // Otherwise we'll just use the expanded width
+ availableWidth = expandedWidth;
+ }
+ }
+
+ if (availableWidth > 0) {
+ updateDrawText = (currentTitleTextSize != newTextSize) || boundsChanged || updateDrawText;
+ currentTitleTextSize = newTextSize;
+ boundsChanged = false;
+ }
+
+ if (titleToDraw == null || updateDrawText) {
+ titleTextPaint.setTextSize(currentTitleTextSize);
+ titleTextPaint.setTypeface(currentTitleTypeface);
+ // Use linear title scaling if we're scaling the canvas
+ titleTextPaint.setLinearText(titleScale != 1f);
+
+ // If we don't currently have title to draw, or the title size has changed, ellipsize...
+ final CharSequence text =
+ TextUtils
+ .ellipsize(this.title, titleTextPaint, availableWidth, TextUtils.TruncateAt.END);
+ if (!TextUtils.equals(text, titleToDraw)) {
+ titleToDraw = text;
+ isRtl = calculateIsRtl(titleToDraw);
+ }
+ }
+ }
+
+ @SuppressWarnings("ReferenceEquality") // Matches the Typeface comparison in TextView
+ private void calculateUsingSubtitleTextSize(final float size) {
+ if (subtitle == null) {
+ return;
+ }
+
+ final float collapsedWidth = collapsedBounds.width();
+ final float expandedWidth = expandedBounds.width();
+
+ final float availableWidth;
+ final float newTextSize;
+ boolean updateDrawText = false;
+
+ if (isClose(size, collapsedSubtitleTextSize)) {
+ newTextSize = collapsedSubtitleTextSize;
+ subtitleScale = 1f;
+ if (currentSubtitleTypeface != collapsedSubtitleTypeface) {
+ currentSubtitleTypeface = collapsedSubtitleTypeface;
+ updateDrawText = true;
+ }
+ availableWidth = collapsedWidth;
+ } else {
+ newTextSize = expandedSubtitleTextSize;
+ if (currentSubtitleTypeface != expandedSubtitleTypeface) {
+ currentSubtitleTypeface = expandedSubtitleTypeface;
+ updateDrawText = true;
+ }
+ if (isClose(size, expandedSubtitleTextSize)) {
+ // If we're close to the expanded title size, snap to it and use a scale of 1
+ subtitleScale = 1f;
+ } else {
+ // Else, we'll scale down from the expanded title size
+ subtitleScale = size / expandedSubtitleTextSize;
+ }
+
+ final float textSizeRatio = collapsedSubtitleTextSize / expandedSubtitleTextSize;
+ // This is the size of the expanded bounds when it is scaled to match the
+ // collapsed title size
+ final float scaledDownWidth = expandedWidth * textSizeRatio;
+
+ if (scaledDownWidth > collapsedWidth) {
+ // If the scaled down size is larger than the actual collapsed width, we need to
+ // cap the available width so that when the expanded title scales down, it matches
+ // the collapsed width
+ availableWidth = Math.min(collapsedWidth / textSizeRatio, expandedWidth);
+ } else {
+ // Otherwise we'll just use the expanded width
+ availableWidth = expandedWidth;
+ }
+ }
+
+ if (availableWidth > 0) {
+ updateDrawText = (currentSubtitleTextSize != newTextSize) || boundsChanged || updateDrawText;
+ currentSubtitleTextSize = newTextSize;
+ boundsChanged = false;
+ }
+
+ if (subtitleToDraw == null || updateDrawText) {
+ subtitleTextPaint.setTextSize(currentSubtitleTextSize);
+ subtitleTextPaint.setTypeface(currentSubtitleTypeface);
+ // Use linear title scaling if we're scaling the canvas
+ subtitleTextPaint.setLinearText(subtitleScale != 1f);
+
+ // If we don't currently have title to draw, or the title size has changed, ellipsize...
+ final CharSequence text =
+ TextUtils.ellipsize(this.subtitle, subtitleTextPaint, availableWidth, TextUtils.TruncateAt.END);
+ if (!TextUtils.equals(text, subtitleToDraw)) {
+ subtitleToDraw = text;
+ isRtl = calculateIsRtl(subtitleToDraw);
+ }
+ }
+ }
+
+ private void ensureExpandedTitleTexture() {
+ if (expandedTitleTexture != null || expandedBounds.isEmpty() || TextUtils.isEmpty(titleToDraw)) {
+ return;
+ }
+
+ calculateOffsets(0f);
+ titleTextureAscent = titleTextPaint.ascent();
+ titleTextureDescent = titleTextPaint.descent();
+
+ final int w = Math.round(titleTextPaint.measureText(titleToDraw, 0, titleToDraw.length()));
+ final int h = Math.round(titleTextureDescent - titleTextureAscent);
+
+ if (w <= 0 || h <= 0) {
+ return; // If the width or height are 0, return
+ }
+
+ expandedTitleTexture = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
+
+ Canvas c = new Canvas(expandedTitleTexture);
+ c.drawText(titleToDraw, 0, titleToDraw.length(), 0, h - titleTextPaint.descent(), titleTextPaint);
+
+ if (titleTexturePaint == null) {
+ // Make sure we have a paint
+ titleTexturePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
+ }
+ }
+
+ private void ensureExpandedSubtitleTexture() {
+ if (expandedSubtitleTexture != null || expandedBounds.isEmpty() || TextUtils.isEmpty(subtitleToDraw)) {
+ return;
+ }
+
+ calculateOffsets(0f);
+ subtitleTextureAscent = subtitleTextPaint.ascent();
+ subtitleTextureDescent = subtitleTextPaint.descent();
+
+ final int w = Math.round(subtitleTextPaint.measureText(subtitleToDraw, 0, subtitleToDraw.length()));
+ final int h = Math.round(subtitleTextureDescent - subtitleTextureAscent);
+
+ if (w <= 0 || h <= 0) {
+ return; // If the width or height are 0, return
+ }
+
+ expandedSubtitleTexture = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
+
+ Canvas c = new Canvas(expandedSubtitleTexture);
+ c.drawText(subtitleToDraw, 0, subtitleToDraw.length(), 0, h - subtitleTextPaint.descent(), subtitleTextPaint);
+
+ if (subtitleTexturePaint == null) {
+ // Make sure we have a paint
+ subtitleTexturePaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
+ }
+ }
+
+ public void recalculate() {
+ if (view.getHeight() > 0 && view.getWidth() > 0) {
+ // If we've already been laid out, calculate everything now otherwise we'll wait
+ // until a layout
+ calculateBaseOffsets();
+ calculateCurrentOffsets();
+ }
+ }
+
+ /**
+ * Set the title to display
+ *
+ * @param title
+ */
+ public void setTitle(@Nullable CharSequence title) {
+ if (title == null || !title.equals(this.title)) {
+ this.title = title;
+ titleToDraw = null;
+ clearTexture();
+ recalculate();
+ }
+ }
+
+ @Nullable
+ public CharSequence getTitle() {
+ return title;
+ }
+
+ /**
+ * Set the subtitle to display
+ *
+ * @param subtitle
+ */
+ public void setSubtitle(@Nullable CharSequence subtitle) {
+ if (subtitle == null || !subtitle.equals(this.subtitle)) {
+ this.subtitle = subtitle;
+ subtitleToDraw = null;
+ clearTexture();
+ recalculate();
+ }
+ }
+
+ @Nullable
+ public CharSequence getSubtitle() {
+ return subtitle;
+ }
+
+ private void clearTexture() {
+ if (expandedTitleTexture != null) {
+ expandedTitleTexture.recycle();
+ expandedTitleTexture = null;
+ }
+ if (expandedSubtitleTexture != null) {
+ expandedSubtitleTexture.recycle();
+ expandedSubtitleTexture = null;
+ }
+ }
+
+ /**
+ * Returns true if {@code value} is 'close' to it's closest decimal value. Close is currently
+ * defined as it's difference being < 0.001.
+ */
+ private static boolean isClose(float value, float targetValue) {
+ return Math.abs(value - targetValue) < 0.001f;
+ }
+
+ public ColorStateList getExpandedTitleTextColor() {
+ return expandedTitleTextColor;
+ }
+
+ public ColorStateList getExpandedSubtitleTextColor() {
+ return expandedSubtitleTextColor;
+ }
+
+ public ColorStateList getCollapsedTitleTextColor() {
+ return collapsedTitleTextColor;
+ }
+
+ public ColorStateList getCollapsedSubtitleTextColor() {
+ return collapsedSubtitleTextColor;
+ }
+
+ /**
+ * Blend {@code color1} and {@code color2} using the given ratio.
+ *
+ * @param ratio of which to blend. 0.0 will return {@code color1}, 0.5 will give an even blend,
+ * 1.0 will return {@code color2}.
+ */
+ private static int blendColors(int color1, int color2, float ratio) {
+ final float inverseRatio = 1f - ratio;
+ float a = (Color.alpha(color1) * inverseRatio) + (Color.alpha(color2) * ratio);
+ float r = (Color.red(color1) * inverseRatio) + (Color.red(color2) * ratio);
+ float g = (Color.green(color1) * inverseRatio) + (Color.green(color2) * ratio);
+ float b = (Color.blue(color1) * inverseRatio) + (Color.blue(color2) * ratio);
+ return Color.argb((int) a, (int) r, (int) g, (int) b);
+ }
+
+ private static float lerp(
+ float startValue, float endValue, float fraction, @Nullable TimeInterpolator interpolator) {
+ if (interpolator != null) {
+ fraction = interpolator.getInterpolation(fraction);
+ }
+ return AnimationUtils.lerp(startValue, endValue, fraction);
+ }
+
+ private static boolean rectEquals(@NonNull Rect r, int left, int top, int right, int bottom) {
+ return !(r.left != left || r.top != top || r.right != right || r.bottom != bottom);
+ }
+}
diff --git a/app/src/main/java/org/lsposed/manager/App.java b/app/src/main/java/org/lsposed/manager/App.java
index 624cda23..1851a1e3 100644
--- a/app/src/main/java/org/lsposed/manager/App.java
+++ b/app/src/main/java/org/lsposed/manager/App.java
@@ -195,7 +195,6 @@ public class App extends Application {
}, new IntentFilter(Intent.ACTION_PACKAGE_CHANGED));
UpdateUtil.loadRemoteVersion();
- RepoLoader.getInstance().loadRemoteData();
executorService.submit(HTML_TEMPLATE);
executorService.submit(HTML_TEMPLATE_DARK);
diff --git a/app/src/main/java/org/lsposed/manager/ConfigManager.java b/app/src/main/java/org/lsposed/manager/ConfigManager.java
index 11dc189e..48f2b21b 100644
--- a/app/src/main/java/org/lsposed/manager/ConfigManager.java
+++ b/app/src/main/java/org/lsposed/manager/ConfigManager.java
@@ -36,7 +36,6 @@ import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
-import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
diff --git a/app/src/main/java/org/lsposed/manager/adapters/AppHelper.java b/app/src/main/java/org/lsposed/manager/adapters/AppHelper.java
index dea21987..c184a28d 100644
--- a/app/src/main/java/org/lsposed/manager/adapters/AppHelper.java
+++ b/app/src/main/java/org/lsposed/manager/adapters/AppHelper.java
@@ -26,11 +26,8 @@ import android.content.pm.ApplicationInfo;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
-import android.os.Looper;
import android.view.MenuItem;
-import org.lsposed.lspd.models.Application;
-import org.lsposed.manager.App;
import org.lsposed.manager.ConfigManager;
import org.lsposed.manager.R;
@@ -153,6 +150,6 @@ public class AppHelper {
public static CharSequence getAppLabel(PackageInfo info, PackageManager pm) {
if (info == null || info.applicationInfo == null) return null;
- return appLabel.computeIfAbsent(info, i->i.applicationInfo.loadLabel(pm));
+ return appLabel.computeIfAbsent(info, i -> i.applicationInfo.loadLabel(pm));
}
}
diff --git a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java b/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java
index 2b4b1f9e..593abba3 100644
--- a/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java
+++ b/app/src/main/java/org/lsposed/manager/adapters/ScopeAdapter.java
@@ -33,8 +33,6 @@ import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
-import android.os.Handler;
-import android.os.HandlerThread;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
@@ -61,7 +59,6 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.google.android.material.checkbox.MaterialCheckBox;
-import com.google.android.material.snackbar.Snackbar;
import org.lsposed.lspd.models.Application;
import org.lsposed.manager.App;
@@ -96,8 +93,6 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter {
+ isLoaded = loaded;
+ notifyDataSetChanged();
+ });
+ }
+
public boolean onOptionsItemSelected(MenuItem item) {
int itemId = item.getItemId();
if (itemId == R.id.use_recommended) {
if (!checkedList.isEmpty()) {
new BlurBehindDialogBuilder(activity)
.setMessage(R.string.use_recommended_message)
- .setPositiveButton(android.R.string.ok, (dialog, which) -> {
- checkRecommended();
- })
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> checkRecommended())
.setNegativeButton(android.R.string.cancel, null)
.show();
} else {
@@ -244,14 +243,6 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter scopeList = module.getScopeList();
if (scopeList == null || scopeList.isEmpty()) {
menu.removeItem(R.id.use_recommended);
@@ -465,26 +452,17 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter {
+ fragment.runAsync(() -> {
List appList = AppHelper.getAppList(force);
denyList = AppHelper.getDenyList(force);
var tmpRecList = new HashSet();
@@ -545,10 +523,7 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter {
- refreshing = false;
- fragment.runOnUiThread((this::notifyDataSetChanged));
- });
+ fragment.runOnUiThread(() -> getFilter().filter(queryStr));
});
}
@@ -560,7 +535,7 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter ConfigManager.reboot(false))
- .show();
+ fragment.showHint(R.string.reboot_required, true, R.string.reboot, v -> ConfigManager.reboot(false));
} else if (denyList.contains(appInfo.packageName)) {
- Snackbar.make(fragment.binding.snackbar, activity.getString(R.string.deny_list, appInfo.label), Snackbar.LENGTH_SHORT)
- .show();
+ fragment.showHint(activity.getString(R.string.deny_list, appInfo.label), true);
}
checkedList = tmpChkList;
}
@Override
public boolean isLoaded() {
- return !refreshing;
+ return isLoaded;
}
static class ViewHolder extends RecyclerView.ViewHolder {
@@ -613,15 +585,11 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter filtered = new ArrayList<>();
- if (constraint.toString().isEmpty()) {
- filtered.addAll(searchList);
- } else {
- String filter = constraint.toString().toLowerCase();
- for (AppInfo info : searchList) {
- if (lowercaseContains(info.label.toString(), filter)
- || lowercaseContains(info.packageName, filter)) {
- filtered.add(info);
- }
+ String filter = constraint.toString().toLowerCase();
+ for (AppInfo info : searchList) {
+ if (lowercaseContains(info.label.toString(), filter)
+ || lowercaseContains(info.packageName, filter)) {
+ filtered.add(info);
}
}
filterResults.values = filtered;
@@ -633,6 +601,7 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter) results.values;
+ setLoaded(true);
}
}
@@ -640,13 +609,13 @@ public class ScopeAdapter extends EmptyStateRecyclerView.EmptyStateAdapter {
- checkRecommended();
- });
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> checkRecommended());
} else {
builder.setPositiveButton(android.R.string.cancel, null);
}
diff --git a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java
index 18759c20..a3461479 100644
--- a/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java
+++ b/app/src/main/java/org/lsposed/manager/repo/RepoLoader.java
@@ -23,6 +23,7 @@ package org.lsposed.manager.repo;
import android.util.Log;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import com.google.gson.Gson;
@@ -37,10 +38,9 @@ import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
-import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.CopyOnWriteArrayList;
import okhttp3.Call;
import okhttp3.Callback;
@@ -51,24 +51,25 @@ import okhttp3.ResponseBody;
public class RepoLoader {
private static RepoLoader instance = null;
private Map onlineModules = new HashMap<>();
+ private Map latestVersion = new ConcurrentHashMap<>();
public static class ModuleVersion {
public String versionName;
public long versionCode;
+
private ModuleVersion(long versionCode, String versionName) {
this.versionName = versionName;
this.versionCode = versionCode;
}
+
public boolean upgradable(long versionCode, String versionName) {
return this.versionCode > versionCode || (this.versionCode == versionCode && !versionName.equals(this.versionName));
}
}
- private final Map latestVersion = new ConcurrentHashMap<>();
private final Path repoFile = Paths.get(App.getInstance().getFilesDir().getAbsolutePath(), "repo.json");
- private final List listeners = new CopyOnWriteArrayList<>();
- private boolean isLoading = false;
+ private final Set listeners = ConcurrentHashMap.newKeySet();
private boolean repoLoaded = false;
private static final String originRepoUrl = "https://modules.lsposed.org/";
private static final String backupRepoUrl = "https://cdn.jsdelivr.net/gh/Xposed-Modules-Repo/modules@gh-pages/";
@@ -81,91 +82,78 @@ public class RepoLoader {
public static synchronized RepoLoader getInstance() {
if (instance == null) {
instance = new RepoLoader();
- instance.loadRemoteData();
+ App.getExecutorService().submit(instance::loadRemoteData);
}
return instance;
}
- public void loadRemoteData() {
- synchronized (this) {
- if (isLoading) {
- return;
- }
- isLoading = true;
- }
- App.getOkHttpClient().newCall(new Request.Builder()
- .url(repoUrl + "modules.json")
- .build()).enqueue(new Callback() {
- @Override
- public void onFailure(@NonNull Call call, @NonNull IOException e) {
- Log.e(App.TAG, call.request().url().toString(), e);
- for (Listener listener : listeners) {
- listener.onThrowable(e);
- }
- synchronized (this) {
- isLoading = false;
- if (!repoUrl.equals(backupRepoUrl)) {
- repoUrl = backupRepoUrl;
- loadRemoteData();
- }
- }
- }
+ synchronized public void loadRemoteData() {
+ repoLoaded = true;
+ try {
+ var response = App.getOkHttpClient().newCall(new Request.Builder()
+ .url(repoUrl + "modules.json")
+ .build()).execute();
- @Override
- public void onResponse(@NonNull Call call, @NonNull Response response) {
- if (response.isSuccessful()) {
- ResponseBody body = response.body();
- if (body != null) {
- try {
- String bodyString = body.string();
- Gson gson = new Gson();
- Map modules = new HashMap<>();
- OnlineModule[] repoModules = gson.fromJson(bodyString, OnlineModule[].class);
- Arrays.stream(repoModules).forEach(onlineModule -> modules.put(onlineModule.getName(), onlineModule));
+ if (response.isSuccessful()) {
+ ResponseBody body = response.body();
+ if (body != null) {
+ try {
+ String bodyString = body.string();
+ Gson gson = new Gson();
+ Map modules = new HashMap<>();
+ OnlineModule[] repoModules = gson.fromJson(bodyString, OnlineModule[].class);
+ Arrays.stream(repoModules).forEach(onlineModule -> modules.put(onlineModule.getName(), onlineModule));
- latestVersion.clear();
- for (var module : repoModules) {
- var release = module.getLatestRelease();
- if (release == null || release.isEmpty()) continue;
- var splits = release.split("-", 2);
- if (splits.length < 2) continue;
- long verCode;
- String verName;
- try {
- verCode = Long.parseLong(splits[0]);
- verName = splits[1];
- } catch (NumberFormatException ignored) {
- continue;
- }
- String pkgName = module.getName();
- latestVersion.put(pkgName, new ModuleVersion(verCode, verName));
+ Map versions = new ConcurrentHashMap<>();
+ for (var module : repoModules) {
+ var release = module.getLatestRelease();
+ if (release == null || release.isEmpty()) continue;
+ var splits = release.split("-", 2);
+ if (splits.length < 2) continue;
+ long verCode;
+ String verName;
+ try {
+ verCode = Long.parseLong(splits[0]);
+ verName = splits[1];
+ } catch (NumberFormatException ignored) {
+ continue;
}
+ String pkgName = module.getName();
+ versions.put(pkgName, new ModuleVersion(verCode, verName));
+ }
- onlineModules = modules;
- Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8));
- synchronized (this) {
- repoLoaded = true;
- }
- for (Listener listener : listeners) {
- listener.repoLoaded();
- }
- } catch (Throwable t) {
- Log.e(App.TAG, Log.getStackTraceString(t));
- for (Listener listener : listeners) {
- listener.onThrowable(t);
- }
+ latestVersion = versions;
+ onlineModules = modules;
+ Files.write(repoFile, bodyString.getBytes(StandardCharsets.UTF_8));
+ repoLoaded = true;
+ for (RepoListener listener : listeners) {
+ listener.onRepoLoaded();
+ }
+ } catch (Throwable t) {
+ Log.e(App.TAG, Log.getStackTraceString(t));
+ for (RepoListener listener : listeners) {
+ listener.onThrowable(t);
}
}
}
- synchronized (this) {
- isLoading = false;
- }
}
- });
+ } catch (Throwable e) {
+ Log.e(App.TAG, "load remote data", e);
+ for (RepoListener listener : listeners) {
+ listener.onThrowable(e);
+ }
+ if (!repoUrl.equals(backupRepoUrl)) {
+ repoUrl = backupRepoUrl;
+ loadRemoteData();
+ }
+ } finally {
+ repoLoaded = true;
+ }
}
+ @Nullable
public ModuleVersion getModuleLatestVersion(String packageName) {
- return latestVersion.get(packageName);
+ return repoLoaded ? latestVersion.getOrDefault(packageName, null) : null;
}
public void loadRemoteReleases(String packageName) {
@@ -179,7 +167,7 @@ public class RepoLoader {
repoUrl = backupRepoUrl;
loadRemoteReleases(packageName);
} else {
- for (Listener listener : listeners) {
+ for (RepoListener listener : listeners) {
listener.onThrowable(e);
}
}
@@ -196,12 +184,12 @@ public class RepoLoader {
OnlineModule module = gson.fromJson(bodyString, OnlineModule.class);
module.releasesLoaded = true;
onlineModules.replace(packageName, module);
- for (Listener listener : listeners) {
- listener.moduleReleasesLoaded(module);
+ for (RepoListener listener : listeners) {
+ listener.onModuleReleasesLoaded(module);
}
} catch (Throwable t) {
Log.e(App.TAG, Log.getStackTraceString(t));
- for (Listener listener : listeners) {
+ for (RepoListener listener : listeners) {
listener.onThrowable(t);
}
}
@@ -211,28 +199,30 @@ public class RepoLoader {
});
}
- public void addListener(Listener listener) {
+ public void addListener(RepoListener listener) {
if (!listeners.contains(listener))
listeners.add(listener);
}
- public void removeListener(Listener listener) {
+ public void removeListener(RepoListener listener) {
listeners.remove(listener);
}
+ @Nullable
public OnlineModule getOnlineModule(String packageName) {
- return packageName == null ? null : onlineModules.get(packageName);
+ return !repoLoaded || packageName == null ? null : onlineModules.get(packageName);
}
+ @Nullable
public Collection getOnlineModules() {
- return onlineModules.values();
+ return repoLoaded ? onlineModules.values() : null;
}
- public interface Listener {
- default void repoLoaded() {
+ public interface RepoListener {
+ default void onRepoLoaded() {
}
- default void moduleReleasesLoaded(OnlineModule module) {
+ default void onModuleReleasesLoaded(OnlineModule module) {
}
default void onThrowable(Throwable t) {
diff --git a/app/src/main/java/org/lsposed/manager/ui/dialog/FlashDialogBuilder.java b/app/src/main/java/org/lsposed/manager/ui/dialog/FlashDialogBuilder.java
index feca0848..d66b3a1b 100644
--- a/app/src/main/java/org/lsposed/manager/ui/dialog/FlashDialogBuilder.java
+++ b/app/src/main/java/org/lsposed/manager/ui/dialog/FlashDialogBuilder.java
@@ -21,6 +21,7 @@ import com.google.android.material.textview.MaterialTextView;
import org.lsposed.manager.App;
import org.lsposed.manager.ConfigManager;
import org.lsposed.manager.R;
+import org.lsposed.manager.databinding.DialogTitleBinding;
import org.lsposed.manager.databinding.DialogWarningBinding;
import java.io.BufferedReader;
@@ -39,18 +40,23 @@ public class FlashDialogBuilder extends BlurBehindDialogBuilder {
var pref = App.getPreferences();
var notes = pref.getString("release_notes", "");
this.zipPath = pref.getString("zip_file", null);
- setTitle(R.string.update_lsposed);
+ LayoutInflater inflater = LayoutInflater.from(context);
+
+ var title = DialogTitleBinding.inflate(inflater).getRoot();
+ title.setText(R.string.update_lsposed);
+ setCustomTitle(title);
textView = new MaterialTextView(context);
var text = notes + "\n\n\n" + context.getString(R.string.update_lsposed_msg) + "\n\n";
textView.setText(text);
textView.setMovementMethod(LinkMovementMethod.getInstance());
+ textView.setTextIsSelectable(true);
- LayoutInflater inflater = LayoutInflater.from(context);
DialogWarningBinding binding = DialogWarningBinding.inflate(inflater, null, false);
binding.container.addView(textView);
rootView = binding.getRoot();
setView(rootView);
+ title.setOnClickListener(v -> rootView.smoothScrollTo(0, 0));
setNegativeButton(android.R.string.cancel, cancel);
setPositiveButton(R.string.install, null);
diff --git a/app/src/main/java/org/lsposed/manager/ui/dialog/InfoDialogBuilder.java b/app/src/main/java/org/lsposed/manager/ui/dialog/InfoDialogBuilder.java
index 1a5f318f..7b5e6673 100644
--- a/app/src/main/java/org/lsposed/manager/ui/dialog/InfoDialogBuilder.java
+++ b/app/src/main/java/org/lsposed/manager/ui/dialog/InfoDialogBuilder.java
@@ -19,11 +19,14 @@
package org.lsposed.manager.ui.dialog;
-import android.content.Context;
+import android.app.Dialog;
import android.os.Build;
+import android.os.Bundle;
import android.view.LayoutInflater;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
import org.lsposed.manager.BuildConfig;
import org.lsposed.manager.ConfigManager;
@@ -34,12 +37,14 @@ import java.util.Locale;
import rikka.core.util.ClipboardUtils;
-public class InfoDialogBuilder extends BlurBehindDialogBuilder {
+public class InfoDialogBuilder extends DialogFragment {
- public InfoDialogBuilder(@NonNull Context context) {
- super(context);
- setTitle(R.string.info);
- DialogInfoBinding binding = DialogInfoBinding.inflate(LayoutInflater.from(context), null, false);
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ var activity = requireActivity();
+ var builder = new BlurBehindDialogBuilder(activity).setTitle(R.string.info);
+ DialogInfoBinding binding = DialogInfoBinding.inflate(LayoutInflater.from(activity), null, false);
if (ConfigManager.isBinderAlive()) {
binding.apiVersion.setText(String.valueOf(ConfigManager.getXposedApiVersion()));
@@ -61,37 +66,38 @@ public class InfoDialogBuilder extends BlurBehindDialogBuilder {
binding.device.setText(getDevice());
binding.systemAbi.setText(Build.SUPPORTED_ABIS[0]);
- setView(binding.getRoot());
+ builder.setView(binding.getRoot());
- setPositiveButton(android.R.string.ok, null);
- String info = context.getString(R.string.info_api_version) +
+ builder.setPositiveButton(android.R.string.ok, null);
+ String info = activity.getString(R.string.info_api_version) +
"\n" +
binding.apiVersion.getText() +
"\n\n" +
- context.getString(R.string.info_api) +
+ activity.getString(R.string.info_api) +
"\n" +
binding.api.getText() +
"\n\n" +
- context.getString(R.string.info_framework_version) +
+ activity.getString(R.string.info_framework_version) +
"\n" +
binding.frameworkVersion.getText() +
"\n\n" +
- context.getString(R.string.info_manager_version) +
+ activity.getString(R.string.info_manager_version) +
"\n" +
binding.managerVersion.getText() +
"\n\n" +
- context.getString(R.string.info_system_version) +
+ activity.getString(R.string.info_system_version) +
"\n" +
binding.systemVersion.getText() +
"\n\n" +
- context.getString(R.string.info_device) +
+ activity.getString(R.string.info_device) +
"\n" +
binding.device.getText() +
"\n\n" +
- context.getString(R.string.info_system_abi) +
+ activity.getString(R.string.info_system_abi) +
"\n" +
binding.systemAbi.getText();
- setNeutralButton(android.R.string.copy, (dialog, which) -> ClipboardUtils.put(context, info));
+ builder.setNeutralButton(android.R.string.copy, (dialog, which) -> ClipboardUtils.put(activity, info));
+ return builder.create();
}
private String getDevice() {
diff --git a/app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialog.java b/app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialog.java
new file mode 100644
index 00000000..6052a90f
--- /dev/null
+++ b/app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialog.java
@@ -0,0 +1,63 @@
+/*
+ * This file is part of LSPosed.
+ *
+ * LSPosed is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LSPosed is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LSPosed. If not, see .
+ *
+ * Copyright (C) 2021 LSPosed Contributors
+ */
+
+package org.lsposed.manager.ui.dialog;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.os.RemoteException;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.FragmentManager;
+
+import org.lsposed.manager.App;
+import org.lsposed.manager.ConfigManager;
+import org.lsposed.manager.R;
+import org.lsposed.manager.receivers.LSPManagerServiceHolder;
+
+public class ShortcutDialog extends DialogFragment {
+ private static boolean shown = false;
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ return new BlurBehindDialogBuilder(requireContext())
+ .setTitle(R.string.parasitic_recommend)
+ .setMessage(R.string.parasitic_recommend_summary)
+ .setNegativeButton(R.string.never_show, (dialog, which) ->
+ App.getPreferences().edit().putBoolean("never_show_shortcut", true).apply())
+ .setNeutralButton(R.string.create_shortcut, (dialog, which) -> {
+ try {
+ LSPManagerServiceHolder.getService().createShortcut();
+ } catch (RemoteException ignored) {
+ }
+ })
+ .setPositiveButton(android.R.string.ok, null).create();
+ }
+
+ public static void showIfNeed(FragmentManager fm) {
+ if (App.isParasitic() || !ConfigManager.isBinderAlive()) return;
+ if (App.getPreferences().getBoolean("never_show_shortcut", false)) return;
+ if (shown) return;
+ shown = true;
+ new ShortcutDialog().show(fm, "shortcut");
+ }
+}
diff --git a/app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialogBuilder.java b/app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialogBuilder.java
deleted file mode 100644
index 74c19e8f..00000000
--- a/app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialogBuilder.java
+++ /dev/null
@@ -1,38 +0,0 @@
-package org.lsposed.manager.ui.dialog;
-
-import android.content.Context;
-import android.os.RemoteException;
-
-import androidx.annotation.NonNull;
-
-import org.lsposed.manager.App;
-import org.lsposed.manager.ConfigManager;
-import org.lsposed.manager.R;
-import org.lsposed.manager.receivers.LSPManagerServiceHolder;
-
-public class ShortcutDialogBuilder extends BlurBehindDialogBuilder {
- private static boolean shown = false;
-
- private ShortcutDialogBuilder(@NonNull Context context) {
- super(context);
- setTitle(R.string.parasitic_recommend);
- setMessage(R.string.parasitic_recommend_summary);
- setNegativeButton(R.string.never_show, (dialog, which) ->
- App.getPreferences().edit().putBoolean("never_show_shortcut", true).apply());
- setNeutralButton(R.string.create_shortcut, (dialog, which) -> {
- try {
- LSPManagerServiceHolder.getService().createShortcut();
- } catch (RemoteException ignored) {
- }
- });
- setPositiveButton(android.R.string.ok, null);
- }
-
- public static void showIfNeed(@NonNull Context context) {
- if (App.isParasitic() || !ConfigManager.isBinderAlive()) return;
- if (App.getPreferences().getBoolean("never_show_shortcut", false)) return;
- if (shown) return;
- shown = true;
- new ShortcutDialogBuilder(context).show();
- }
-}
diff --git a/app/src/main/java/org/lsposed/manager/ui/dialog/WarningDialogBuilder.java b/app/src/main/java/org/lsposed/manager/ui/dialog/WarningDialogBuilder.java
index c9c29ef4..71daadd1 100644
--- a/app/src/main/java/org/lsposed/manager/ui/dialog/WarningDialogBuilder.java
+++ b/app/src/main/java/org/lsposed/manager/ui/dialog/WarningDialogBuilder.java
@@ -19,13 +19,15 @@
package org.lsposed.manager.ui.dialog;
-import android.app.Activity;
-import android.content.Context;
+import android.app.Dialog;
+import android.os.Bundle;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
+import androidx.fragment.app.DialogFragment;
import org.lsposed.manager.ConfigManager;
import org.lsposed.manager.R;
@@ -33,14 +35,15 @@ import org.lsposed.manager.databinding.DialogItemBinding;
import org.lsposed.manager.databinding.DialogWarningBinding;
import org.lsposed.manager.util.chrome.LinkTransformationMethod;
-public class WarningDialogBuilder extends BlurBehindDialogBuilder {
+public class WarningDialogBuilder extends DialogFragment {
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ var activity = requireActivity();
+ var builder = new BlurBehindDialogBuilder(activity).
+ setTitle(R.string.partial_activated);
- public WarningDialogBuilder(@NonNull Context context) {
- super(context);
- Activity activity = (Activity) context;
- setTitle(R.string.partial_activated);
-
- LayoutInflater inflater = LayoutInflater.from(context);
+ LayoutInflater inflater = LayoutInflater.from(activity);
DialogWarningBinding binding = DialogWarningBinding.inflate(inflater, null, false);
if (!ConfigManager.isSepolicyLoaded()) {
@@ -65,7 +68,9 @@ public class WarningDialogBuilder extends BlurBehindDialogBuilder {
item.value.setTransformationMethod(new LinkTransformationMethod(activity));
}
- setView(binding.getRoot());
- setPositiveButton(android.R.string.ok, null);
+ builder.setView(binding.getRoot());
+ builder.setPositiveButton(android.R.string.ok, null);
+ builder.setNeutralButton(R.string.info, (dialog, which) -> new InfoDialogBuilder().show(getParentFragmentManager(), "info"));
+ return builder.create();
}
}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java
index ad2da238..fb3f63f2 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/AppListFragment.java
@@ -19,13 +19,13 @@
package org.lsposed.manager.ui.fragment;
+import android.content.Intent;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
@@ -36,10 +36,10 @@ import androidx.appcompat.widget.SearchView;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
-import com.google.android.material.snackbar.Snackbar;
-
import org.lsposed.manager.App;
+import org.lsposed.manager.ConfigManager;
import org.lsposed.manager.R;
+import org.lsposed.manager.adapters.AppHelper;
import org.lsposed.manager.adapters.ScopeAdapter;
import org.lsposed.manager.databinding.FragmentAppListBinding;
import org.lsposed.manager.util.BackupUtils;
@@ -60,6 +60,15 @@ public class AppListFragment extends BaseFragment {
public ActivityResultLauncher backupLauncher;
public ActivityResultLauncher restoreLauncher;
+ private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() {
+ @Override
+ public void onChanged() {
+ if (binding != null && scopeAdapter != null) {
+ binding.swipeRefreshLayout.setRefreshing(!scopeAdapter.isLoaded());
+ }
+ }
+ };
+
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
@@ -68,7 +77,6 @@ public class AppListFragment extends BaseFragment {
return binding.getRoot();
}
binding.appBar.setLiftable(true);
- binding.appBar.setLifted(true);
String title;
if (module.userId != 0) {
title = String.format(Locale.ROOT, "%s (%d)", module.getAppName(), module.userId);
@@ -79,24 +87,33 @@ public class AppListFragment extends BaseFragment {
scopeAdapter = new ScopeAdapter(this, module);
scopeAdapter.setHasStableIds(true);
- scopeAdapter.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
- @Override
- public void onChanged() {
- if (binding != null && scopeAdapter != null) {
- binding.progress.setVisibility(scopeAdapter.isLoaded() ? View.GONE : View.VISIBLE);
- binding.swipeRefreshLayout.setRefreshing(!scopeAdapter.isLoaded());
- }
- }
- });
+ scopeAdapter.registerAdapterDataObserver(observer);
binding.recyclerView.setAdapter(scopeAdapter);
binding.recyclerView.setHasFixedSize(true);
binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));
+ binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> binding.appBar.setLifted(!top));
RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true);
- binding.swipeRefreshLayout.setOnRefreshListener(() -> scopeAdapter.refresh());
-
+ binding.swipeRefreshLayout.setOnRefreshListener(() -> scopeAdapter.refresh(true));
+ binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset());
+ Intent intent = AppHelper.getSettingsIntent(module.packageName, module.userId);
+ if (intent == null) {
+ binding.fab.setVisibility(View.GONE);
+ } else {
+ binding.fab.setVisibility(View.VISIBLE);
+ binding.fab.setOnClickListener(v -> ConfigManager.startActivityAsUserWithFeature(intent, module.userId));
+ }
searchListener = scopeAdapter.getSearchListener();
- setupToolbar(binding.toolbar, title, R.menu.menu_app_list, view -> requireActivity().getOnBackPressedDispatcher().onBackPressed());
+ setupToolbar(binding.toolbar, binding.clickView, title, R.menu.menu_app_list, view -> requireActivity().getOnBackPressedDispatcher().onBackPressed());
+ View.OnClickListener l = v -> {
+ if (searchView.isIconified()) {
+ binding.recyclerView.smoothScrollToPosition(0);
+ binding.appBar.setExpanded(true, true);
+ }
+ };
+ binding.toolbar.setOnClickListener(l);
+ binding.clickView.setOnClickListener(l);
+
return binding.getRoot();
}
@@ -116,6 +133,8 @@ public class AppListFragment extends BaseFragment {
int moduleUserId = args.getModuleUserId();
module = ModuleUtil.getInstance().getModule(modulePackageName, moduleUserId);
+ if (module == null)
+ getNavController().navigate(R.id.action_app_list_fragment_to_modules_fragment);
backupLauncher = registerForActivityResult(new ActivityResultContracts.CreateDocument(),
uri -> {
@@ -125,11 +144,7 @@ public class AppListFragment extends BaseFragment {
BackupUtils.backup(uri, modulePackageName);
} catch (Exception e) {
var text = App.getInstance().getString(R.string.settings_backup_failed2, e.getMessage());
- if (binding != null && isResumed()) {
- Snackbar.make(binding.snackbar, text, Snackbar.LENGTH_LONG).show();
- } else {
- Toast.makeText(App.getInstance(), text, Toast.LENGTH_LONG).show();
- }
+ showHint(text, false);
}
});
});
@@ -141,11 +156,7 @@ public class AppListFragment extends BaseFragment {
BackupUtils.restore(uri, modulePackageName);
} catch (Exception e) {
var text = App.getInstance().getString(R.string.settings_restore_failed2, e.getMessage());
- if (binding != null && isResumed()) {
- Snackbar.make(binding.snackbar, text, Snackbar.LENGTH_LONG).show();
- } else {
- Toast.makeText(App.getInstance(), text, Toast.LENGTH_LONG).show();
- }
+ showHint(text, false);
}
});
});
@@ -164,17 +175,10 @@ public class AppListFragment extends BaseFragment {
if (scopeAdapter != null) scopeAdapter.refresh();
}
- @Override
- public void onDestroy() {
- if (scopeAdapter != null) scopeAdapter.onDestroy();
-
- super.onDestroy();
- }
-
@Override
public void onDestroyView() {
super.onDestroyView();
-
+ scopeAdapter.unregisterAdapterDataObserver(observer);
binding = null;
}
@@ -191,6 +195,18 @@ public class AppListFragment extends BaseFragment {
super.onPrepareOptionsMenu(menu);
searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
searchView.setOnQueryTextListener(searchListener);
+ searchView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View arg0) {
+ binding.appBar.setExpanded(false, true);
+ binding.recyclerView.setNestedScrollingEnabled(false);
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v) {
+ binding.recyclerView.setNestedScrollingEnabled(true);
+ }
+ });
scopeAdapter.onPrepareOptionsMenu(menu);
}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/BaseFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/BaseFragment.java
index a100470b..15081e60 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/BaseFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/BaseFragment.java
@@ -21,12 +21,16 @@ package org.lsposed.manager.ui.fragment;
import android.app.Activity;
import android.view.View;
+import android.widget.Toast;
+import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
+import com.google.android.material.snackbar.Snackbar;
+
import org.lsposed.manager.App;
import org.lsposed.manager.R;
@@ -41,22 +45,24 @@ public class BaseFragment extends Fragment {
return NavHostFragment.findNavController(this);
}
- public void setupToolbar(Toolbar toolbar, int title) {
- setupToolbar(toolbar, getString(title), -1);
+ public void setupToolbar(Toolbar toolbar, View tipsView, int title) {
+ setupToolbar(toolbar, tipsView, getString(title), -1);
}
- public void setupToolbar(Toolbar toolbar, int title, int menu) {
- setupToolbar(toolbar, getString(title), menu, null);
+ public void setupToolbar(Toolbar toolbar, View tipsView, int title, int menu) {
+ setupToolbar(toolbar, tipsView, getString(title), menu, null);
}
- public void setupToolbar(Toolbar toolbar, String title, int menu) {
- setupToolbar(toolbar, title, menu, null);
+ public void setupToolbar(Toolbar toolbar, View tipsView, String title, int menu) {
+ setupToolbar(toolbar, tipsView, title, menu, null);
}
- public void setupToolbar(Toolbar toolbar, String title, int menu, View.OnClickListener navigationOnClickListener) {
+ public void setupToolbar(Toolbar toolbar, View tipsView, String title, int menu, View.OnClickListener navigationOnClickListener) {
toolbar.setNavigationOnClickListener(navigationOnClickListener == null ? (v -> navigateUp()) : navigationOnClickListener);
toolbar.setNavigationIcon(R.drawable.ic_baseline_arrow_back_24);
toolbar.setTitle(title);
+ toolbar.setTooltipText(title);
+ if (tipsView != null) tipsView.setTooltipText(title);
if (menu != -1) {
toolbar.inflateMenu(menu);
toolbar.setOnMenuItemClickListener(this::onOptionsItemSelected);
@@ -74,4 +80,29 @@ public class BaseFragment extends Fragment {
activity.runOnUiThread(runnable);
}
}
+
+ public void showHint(@StringRes int res, boolean lengthShort, @StringRes int actionRes, View.OnClickListener action) {
+ showHint(getString(res), lengthShort, getString(actionRes), action);
+ }
+
+ public void showHint(@StringRes int res, boolean lengthShort) {
+ showHint(getString(res), lengthShort, null, null);
+ }
+
+ public void showHint(CharSequence str, boolean lengthShort) {
+ showHint(str, lengthShort, null, null);
+ }
+
+ public void showHint(CharSequence str, boolean lengthShort, CharSequence actionStr, View.OnClickListener action) {
+ if (isResumed()) {
+ var container = requireActivity().findViewById(R.id.container);
+ if (container != null) {
+ var snackbar = Snackbar.make(container, str, lengthShort ? Snackbar.LENGTH_SHORT : Snackbar.LENGTH_LONG);
+ if (actionStr != null && action != null) snackbar.setAction(actionStr, action);
+ snackbar.show();
+ return;
+ }
+ }
+ Toast.makeText(requireContext(), str, lengthShort ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG).show();
+ }
}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java
index 3dc354cf..0bdffef4 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/CompileDialogFragment.java
@@ -31,11 +31,10 @@ import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatDialogFragment;
import androidx.fragment.app.FragmentManager;
-import com.google.android.material.snackbar.Snackbar;
-
import org.lsposed.manager.App;
import org.lsposed.manager.R;
import org.lsposed.manager.databinding.FragmentCompileDialogBinding;
@@ -47,19 +46,21 @@ import java.lang.ref.WeakReference;
@SuppressWarnings("deprecation")
public class CompileDialogFragment extends AppCompatDialogFragment {
private ApplicationInfo appInfo;
- private View snackBar;
- public static void speed(FragmentManager fragmentManager, ApplicationInfo info, View snackBar) {
+ public static void speed(FragmentManager fragmentManager, ApplicationInfo info) {
CompileDialogFragment fragment = new CompileDialogFragment();
fragment.setCancelable(false);
- fragment.appInfo = info;
- fragment.snackBar = snackBar;
+ var bundle = new Bundle();
+ bundle.putParcelable("appInfo", info);
+ fragment.setArguments(bundle);
fragment.show(fragmentManager, "compile_dialog");
}
@Override
@NonNull
public Dialog onCreateDialog(Bundle savedInstanceState) {
+ var arguments = getArguments();
+ appInfo = arguments != null ? arguments.getParcelable("appInfo") : null;
if (appInfo == null) {
throw new IllegalStateException("appInfo should not be null.");
}
@@ -67,7 +68,6 @@ public class CompileDialogFragment extends AppCompatDialogFragment {
FragmentCompileDialogBinding binding = FragmentCompileDialogBinding.inflate(LayoutInflater.from(requireActivity()), null, false);
final PackageManager pm = requireContext().getPackageManager();
var builder = new BlurBehindDialogBuilder(requireActivity())
- .setIcon(appInfo.loadIcon(pm))
.setTitle(appInfo.loadLabel(pm))
.setView(binding.getRoot());
@@ -75,8 +75,8 @@ public class CompileDialogFragment extends AppCompatDialogFragment {
}
@Override
- public void onAttach(@NonNull Context context) {
- super.onAttach(context);
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
new CompileTask(this).executeOnExecutor(App.getExecutorService(), appInfo.packageName);
}
@@ -118,9 +118,8 @@ public class CompileDialogFragment extends AppCompatDialogFragment {
if (fragment != null) {
fragment.dismissAllowingStateLoss();
var parent = fragment.getParentFragment();
- if (fragment.snackBar != null && parent != null && parent.isResumed()) {
- Snackbar.make(fragment.snackBar, text, Snackbar.LENGTH_LONG).show();
- return;
+ if (parent instanceof BaseFragment) {
+ ((BaseFragment) parent).showHint(text, true);
}
}
Toast.makeText(context, text, Toast.LENGTH_LONG).show();
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/HomeFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/HomeFragment.java
index 1cfd67fc..8fc55d18 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/HomeFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/HomeFragment.java
@@ -20,17 +20,18 @@
package org.lsposed.manager.ui.fragment;
import android.app.Activity;
+import android.app.Dialog;
import android.os.Build;
import android.os.Bundle;
import android.text.method.LinkMovementMethod;
import android.view.LayoutInflater;
-import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
+import androidx.fragment.app.DialogFragment;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.snackbar.Snackbar;
@@ -44,7 +45,7 @@ import org.lsposed.manager.repo.RepoLoader;
import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder;
import org.lsposed.manager.ui.dialog.FlashDialogBuilder;
import org.lsposed.manager.ui.dialog.InfoDialogBuilder;
-import org.lsposed.manager.ui.dialog.ShortcutDialogBuilder;
+import org.lsposed.manager.ui.dialog.ShortcutDialog;
import org.lsposed.manager.ui.dialog.WarningDialogBuilder;
import org.lsposed.manager.util.ModuleUtil;
import org.lsposed.manager.util.NavUtil;
@@ -56,7 +57,7 @@ import java.util.Locale;
import rikka.core.util.ResourceUtils;
-public class HomeFragment extends BaseFragment implements RepoLoader.Listener, ModuleUtil.ModuleListener {
+public class HomeFragment extends BaseFragment implements RepoLoader.RepoListener, ModuleUtil.ModuleListener {
private FragmentHomeBinding binding;
@@ -66,15 +67,16 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- ShortcutDialogBuilder.showIfNeed(requireContext());
+ ShortcutDialog.showIfNeed(getChildFragmentManager());
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
binding = FragmentHomeBinding.inflate(inflater, container, false);
- setupToolbar(binding.toolbar, getString(R.string.app_name), R.menu.menu_home);
+ setupToolbar(binding.toolbar, null, R.string.app_name);
binding.toolbar.setNavigationIcon(null);
+ binding.toolbar.setOnClickListener(v -> showAbout());
binding.appBar.setLiftable(true);
binding.nestedScrollView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> binding.appBar.setLifted(!top));
@@ -82,9 +84,9 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
binding.status.setOnClickListener(v -> {
if (ConfigManager.isBinderAlive() && !UpdateUtil.needUpdate()) {
if (!ConfigManager.isSepolicyLoaded() || !ConfigManager.systemServerRequested() || !ConfigManager.dex2oatFlagsLoaded()) {
- new WarningDialogBuilder(activity).show();
+ new WarningDialogBuilder().show(getChildFragmentManager(), "warning");
} else {
- new InfoDialogBuilder(activity).show();
+ new InfoDialogBuilder().show(getChildFragmentManager(), "info");
}
} else {
if (UpdateUtil.canInstall()) {
@@ -105,43 +107,16 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
binding.download.setOnClickListener(new StartFragmentListener(R.id.action_repo_fragment, false));
binding.logs.setOnClickListener(new StartFragmentListener(R.id.action_logs_fragment, true));
binding.settings.setOnClickListener(new StartFragmentListener(R.id.action_settings_fragment, false));
- binding.issue.setOnClickListener(view -> NavUtil.startURL(activity, "https://github.com/LSPosed/LSPosed/issues"));
+ binding.issue.setOnClickListener(view -> NavUtil.startURL(activity, "https://github.com/LSPosed/LSPosed/issues/new/choose"));
updateStates(requireActivity(), ConfigManager.isBinderAlive(), UpdateUtil.needUpdate());
repoLoader.addListener(this);
moduleUtil.addListener(this);
- if (repoLoader.isRepoLoaded()) {
- repoLoaded();
- }
+ onModulesReloaded();
return binding.getRoot();
}
- @Override
- public boolean onOptionsItemSelected(@NonNull MenuItem item) {
- int itemId = item.getItemId();
- if (itemId == R.id.menu_refresh) {
- updateStates(requireActivity(), ConfigManager.isBinderAlive(), UpdateUtil.needUpdate());
- } else if (itemId == R.id.menu_info) {
- new InfoDialogBuilder(requireActivity()).setTitle(R.string.info).show();
- } else if (itemId == R.id.menu_about) {
- Activity activity = requireActivity();
- DialogAboutBinding binding = DialogAboutBinding.inflate(LayoutInflater.from(requireActivity()), null, false);
- binding.designAboutTitle.setText(R.string.app_name);
- binding.designAboutInfo.setMovementMethod(LinkMovementMethod.getInstance());
- binding.designAboutInfo.setTransformationMethod(new LinkTransformationMethod(activity));
- binding.designAboutInfo.setText(HtmlCompat.fromHtml(getString(
- R.string.about_view_source_code,
- "GitHub",
- "Telegram"), HtmlCompat.FROM_HTML_MODE_LEGACY));
- binding.designAboutVersion.setText(String.format(Locale.ROOT, "%s (%s)", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
- new BlurBehindDialogBuilder(activity)
- .setView(binding.getRoot())
- .show();
- }
- return super.onOptionsItemSelected(item);
- }
-
private void updateStates(Activity activity, boolean binderAlive, boolean needUpdate) {
int cardBackgroundColor;
if (binderAlive) {
@@ -186,7 +161,7 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
binding.download.setVisibility(View.GONE);
}
binding.statusIcon.setImageResource(R.drawable.ic_round_error_outline_24);
- Snackbar.make(binding.snackbar, R.string.lsposed_not_active, Snackbar.LENGTH_INDEFINITE).show();
+ showHint(R.string.lsposed_not_active, false);
}
cardBackgroundColor = MaterialColors.harmonizeWithPrimary(activity, cardBackgroundColor);
binding.status.setCardBackgroundColor(cardBackgroundColor);
@@ -194,10 +169,33 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
binding.status.setOutlineSpotShadowColor(cardBackgroundColor);
binding.status.setOutlineAmbientShadowColor(cardBackgroundColor);
}
+ binding.about.setOnClickListener(v -> showAbout());
+ }
+
+ public static class AboutDialog extends DialogFragment {
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ DialogAboutBinding binding = DialogAboutBinding.inflate(LayoutInflater.from(requireActivity()), null, false);
+ binding.designAboutTitle.setText(R.string.app_name);
+ binding.designAboutInfo.setMovementMethod(LinkMovementMethod.getInstance());
+ binding.designAboutInfo.setTransformationMethod(new LinkTransformationMethod(requireActivity()));
+ binding.designAboutInfo.setText(HtmlCompat.fromHtml(getString(
+ R.string.about_view_source_code,
+ "GitHub",
+ "Telegram"), HtmlCompat.FROM_HTML_MODE_LEGACY));
+ binding.designAboutVersion.setText(String.format(Locale.ROOT, "%s (%s)", BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE));
+ return new BlurBehindDialogBuilder(requireContext())
+ .setView(binding.getRoot()).create();
+ }
+ }
+
+ private void showAbout() {
+ new AboutDialog().show(getChildFragmentManager(), "about");
}
@Override
- public void repoLoaded() {
+ public void onRepoLoaded() {
final int[] count = new int[]{0};
HashSet processedModules = new HashSet<>();
var modules = moduleUtil.getModules();
@@ -228,7 +226,7 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
@Override
public void onModulesReloaded() {
- if (repoLoader.isRepoLoaded()) repoLoaded();
+ onRepoLoaded();
setModulesSummary(moduleUtil.getEnabledModulesCount());
}
@@ -244,7 +242,7 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
@Override
public void onClick(View v) {
if (requireInstalled && !ConfigManager.isBinderAlive()) {
- Snackbar.make(binding.snackbar, R.string.lsposed_not_active, Snackbar.LENGTH_LONG).show();
+ showHint(R.string.lsposed_not_active, false);
} else {
getNavController().navigate(fragment);
}
@@ -260,7 +258,7 @@ public class HomeFragment extends BaseFragment implements RepoLoader.Listener, M
}
private void setModulesSummary(int moduleCount) {
- runOnUiThread(() -> binding.modulesSummary.setText(moduleCount == - 1? getString(R.string.loading) : getResources().getQuantityString(R.plurals.modules_enabled_count, moduleCount, moduleCount)));
+ runOnUiThread(() -> binding.modulesSummary.setText(moduleCount == -1 ? getString(R.string.loading) : getResources().getQuantityString(R.plurals.modules_enabled_count, moduleCount, moduleCount)));
}
@Override
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/LogsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/LogsFragment.java
index f5ba8bf2..297413bf 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/LogsFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/LogsFragment.java
@@ -14,57 +14,74 @@
* You should have received a copy of the GNU General Public License
* along with LSPosed. If not, see .
*
- * Copyright (C) 2020 EdXposed Contributors
* Copyright (C) 2021 LSPosed Contributors
*/
package org.lsposed.manager.ui.fragment;
-import static org.lsposed.manager.App.TAG;
-
import android.annotation.SuppressLint;
-import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
-import android.os.ParcelFileDescriptor;
import android.util.Log;
import android.view.LayoutInflater;
+import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.ScrollView;
-import android.widget.Toast;
+import android.widget.HorizontalScrollView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
-import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+import com.google.android.material.textview.MaterialTextView;
+import org.lsposed.manager.App;
import org.lsposed.manager.ConfigManager;
import org.lsposed.manager.R;
-import org.lsposed.manager.databinding.FragmentLogsBinding;
-import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder;
+import org.lsposed.manager.databinding.FragmentPagerBinding;
+import org.lsposed.manager.databinding.ItemLogTextviewBinding;
+import org.lsposed.manager.databinding.SwiperefreshRecyclerviewBinding;
+import org.lsposed.manager.ui.widget.EmptyStateRecyclerView;
-import java.io.ByteArrayOutputStream;
+import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
+import java.io.InputStreamReader;
import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
import java.util.Locale;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import rikka.core.os.FileUtils;
+import rikka.recyclerview.RecyclerViewKt;
public class LogsFragment extends BaseFragment {
- private boolean verbose = false;
private final Handler handler = new Handler(Looper.getMainLooper());
- private FragmentLogsBinding binding;
+ private FragmentPagerBinding binding;
+ private LogPageAdapter adapter;
+ private MenuItem wordWrap;
+
+ interface OptionsItemSelectListener {
+ boolean onOptionsItemSelected(@NonNull MenuItem item);
+ }
+
+ private OptionsItemSelectListener optionsItemSelectListener;
+
private final ActivityResultLauncher saveLogsLauncher = registerForActivityResult(
new ActivityResultContracts.CreateDocument(),
uri -> {
@@ -78,11 +95,7 @@ public class LogsFragment extends BaseFragment {
os.finish();
} catch (IOException e) {
var text = context.getString(R.string.logs_save_failed2, e.getMessage());
- if (binding != null && isResumed()) {
- Snackbar.make(binding.snackbar, text, Snackbar.LENGTH_LONG).show();
- } else {
- Toast.makeText(context, text, Toast.LENGTH_LONG).show();
- }
+ showHint(text, false);
}
});
});
@@ -90,54 +103,57 @@ public class LogsFragment extends BaseFragment {
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
- binding = FragmentLogsBinding.inflate(inflater, container, false);
- setupToolbar(binding.toolbar, R.string.Logs, R.menu.menu_logs);
-
- binding.slidingTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
- @Override
- public void onTabSelected(TabLayout.Tab tab) {
- verbose = tab.getPosition() == 1;
- reloadLogs();
- }
-
- @Override
- public void onTabUnselected(TabLayout.Tab tab) {
-
- }
-
- @Override
- public void onTabReselected(TabLayout.Tab tab) {
+ binding = FragmentPagerBinding.inflate(inflater, container, false);
+ binding.appBar.setLiftable(true);
+ setupToolbar(binding.toolbar, binding.clickView, R.string.Logs, R.menu.menu_logs);
+ binding.toolbar.setSubtitle(ConfigManager.isVerboseLogEnabled() ? R.string.enabled_verbose_log : R.string.disabled_verbose_log);
+ adapter = new LogPageAdapter(this);
+ binding.viewPager.setAdapter(adapter);
+ new TabLayoutMediator(binding.tabLayout, binding.viewPager, (tab, position) -> tab.setText((int) adapter.getItemId(position))).attach();
+ binding.tabLayout.addOnLayoutChangeListener((view, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
+ ViewGroup vg = (ViewGroup) binding.tabLayout.getChildAt(0);
+ int tabLayoutWidth = IntStream.range(0, binding.tabLayout.getTabCount()).map(i -> vg.getChildAt(i).getWidth()).sum();
+ if (tabLayoutWidth <= binding.getRoot().getWidth()) {
+ binding.tabLayout.setTabMode(TabLayout.MODE_FIXED);
+ binding.tabLayout.setTabGravity(TabLayout.GRAVITY_FILL);
}
});
return binding.getRoot();
}
+ public void setOptionsItemSelectListener(OptionsItemSelectListener optionsItemSelectListener) {
+ this.optionsItemSelectListener = optionsItemSelectListener;
+ }
+
+ @SuppressLint("NotifyDataSetChanged")
@Override
- public void onResume() {
- super.onResume();
- reloadLogs();
+ public boolean onOptionsItemSelected(@NonNull MenuItem item) {
+ var itemId = item.getItemId();
+ if (itemId == R.id.menu_save) {
+ save();
+ return true;
+ } else if (itemId == R.id.menu_word_wrap) {
+ item.setChecked(!item.isChecked());
+ App.getPreferences().edit().putBoolean("enable_word_wrap", item.isChecked()).apply();
+ binding.viewPager.setUserInputEnabled(item.isChecked());
+ adapter.refresh();
+ return true;
+ }
+ if (optionsItemSelectListener != null) {
+ if (optionsItemSelectListener.onOptionsItemSelected(item))
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
}
@Override
- public boolean onOptionsItemSelected(@NonNull MenuItem item) {
- int itemId = item.getItemId();
- if (itemId == R.id.menu_scroll_top) {
- binding.scrollView.fullScroll(ScrollView.FOCUS_UP);
- } else if (itemId == R.id.menu_scroll_down) {
- binding.scrollView.fullScroll(ScrollView.FOCUS_DOWN);
- } else if (itemId == R.id.menu_refresh) {
- reloadLogs();
- return true;
- } else if (itemId == R.id.menu_save) {
- save();
- return true;
- } else if (itemId == R.id.menu_clear) {
- clear();
- return true;
- }
- return super.onOptionsItemSelected(item);
+ public void onPrepareOptionsMenu(@NonNull Menu menu) {
+ super.onPrepareOptionsMenu(menu);
+ wordWrap = menu.findItem(R.id.menu_word_wrap);
+ wordWrap.setChecked(App.getPreferences().getBoolean("enable_word_wrap", false));
+ binding.viewPager.setUserInputEnabled(wordWrap.isChecked());
}
@Override
@@ -147,21 +163,6 @@ public class LogsFragment extends BaseFragment {
binding = null;
}
- private void reloadLogs() {
- var parcelFileDescriptor = ConfigManager.getLog(verbose);
- if (parcelFileDescriptor != null)
- new LogsReader().execute(parcelFileDescriptor);
- }
-
- private void clear() {
- if (ConfigManager.clearLogs(verbose)) {
- Snackbar.make(binding.snackbar, R.string.logs_cleared, Snackbar.LENGTH_SHORT).show();
- binding.body.setText("");
- } else {
- Snackbar.make(binding.snackbar, R.string.logs_clear_failed_2, Snackbar.LENGTH_SHORT).show();
- }
- }
-
private void save() {
LocalDateTime now = LocalDateTime.now();
String filename = String.format(Locale.ROOT, "LSPosed_%s.zip", now.toString());
@@ -176,7 +177,7 @@ public class LogsFragment extends BaseFragment {
FileUtils.copy(is, os);
os.closeEntry();
} catch (IOException e) {
- Log.w(TAG, name, e);
+ Log.w(App.TAG, name, e);
}
});
@@ -187,53 +188,7 @@ public class LogsFragment extends BaseFragment {
FileUtils.copy(is, os);
os.closeEntry();
} catch (IOException e) {
- Log.w(TAG, name, e);
- }
- }
-
- @SuppressWarnings("deprecation")
- @SuppressLint("StaticFieldLeak")
- private class LogsReader extends AsyncTask {
- private AlertDialog mProgressDialog;
- private final Runnable mRunnable = () -> {
- synchronized (LogsReader.this) {
- if (!requireActivity().isFinishing()) {
- mProgressDialog.show();
- }
- }
- };
-
- @Override
- synchronized protected void onPreExecute() {
- mProgressDialog = new BlurBehindDialogBuilder(requireActivity()).create();
- mProgressDialog.setMessage(getString(R.string.loading));
- mProgressDialog.setCancelable(false);
- handler.postDelayed(mRunnable, 300);
- }
-
- @Override
- protected String doInBackground(ParcelFileDescriptor... log) {
- Thread.currentThread().setPriority(Thread.NORM_PRIORITY + 2);
- try (var pfd = log[0];
- var inputStream = new FileInputStream(pfd.getFileDescriptor())) {
- int size = Math.toIntExact(pfd.getStatSize()); // max 4MiB
- var logs = new ByteArrayOutputStream(size);
- FileUtils.copy(inputStream, logs);
- return logs.toString();
- } catch (IOException e) {
- return requireActivity().getResources().getString(R.string.logs_cannot_read)
- + "\n" + Log.getStackTraceString(e);
- }
- }
-
- @Override
- synchronized protected void onPostExecute(String logs) {
- binding.body.setText(logs);
-
- handler.removeCallbacks(mRunnable);
- if (mProgressDialog.isShowing()) {
- mProgressDialog.dismiss();
- }
+ Log.w(App.TAG, name, e);
}
}
@@ -243,19 +198,245 @@ public class LogsFragment extends BaseFragment {
super.onDestroy();
}
- @Override
- public void onSaveInstanceState(@NonNull Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putInt(LogsFragment.class.getName() + "." + "tab", binding.slidingTabs.getSelectedTabPosition());
+ public static class LogFragment extends BaseFragment {
+ public static final int SCROLL_THRESHOLD = 500;
+ protected boolean verbose;
+ protected SwiperefreshRecyclerviewBinding binding;
+ protected LogAdaptor adaptor;
+ protected LinearLayoutManager layoutManager;
+
+ class LogAdaptor extends EmptyStateRecyclerView.EmptyStateAdapter {
+ private List log = Collections.emptyList();
+ private boolean isLoaded = false;
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ return new ViewHolder(ItemLogTextviewBinding.inflate(getLayoutInflater(), parent, false));
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ holder.item.setText(log.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return log.size();
+ }
+
+ void refresh() {
+ isLoaded = true;
+ runOnUiThread(this::notifyDataSetChanged);
+ }
+
+ void fullRefresh() {
+ runAsync(() -> {
+ isLoaded = false;
+ try (var parcelFileDescriptor = ConfigManager.getLog(verbose);
+ var br = new BufferedReader(new InputStreamReader(new FileInputStream(parcelFileDescriptor != null ? parcelFileDescriptor.getFileDescriptor() : null)))) {
+ log = br.lines().parallel().collect(Collectors.toList());
+ } catch (Throwable e) {
+ log = Arrays.asList(Log.getStackTraceString(e).split("\n"));
+ } finally {
+ refresh();
+ }
+ });
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return isLoaded;
+ }
+
+ class ViewHolder extends RecyclerView.ViewHolder {
+ final MaterialTextView item;
+
+ public ViewHolder(ItemLogTextviewBinding binding) {
+ super(binding.getRoot());
+ item = binding.logItem;
+ }
+ }
+ }
+
+ protected LogAdaptor createAdaptor() {
+ return new LogAdaptor();
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ binding = SwiperefreshRecyclerviewBinding.inflate(getLayoutInflater(), container, false);
+ var arguments = getArguments();
+ if (arguments == null) return null;
+ verbose = arguments.getBoolean("verbose");
+ adaptor = createAdaptor();
+ binding.recyclerView.setAdapter(adaptor);
+ layoutManager = new LinearLayoutManager(requireActivity());
+ binding.recyclerView.setLayoutManager(layoutManager);
+ binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset());
+ RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true);
+ binding.swipeRefreshLayout.setOnRefreshListener(adaptor::fullRefresh);
+ adaptor.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
+ @Override
+ public void onChanged() {
+ binding.swipeRefreshLayout.setRefreshing(!adaptor.isLoaded());
+ }
+ });
+ adaptor.fullRefresh();
+ return binding.getRoot();
+ }
+
+ public void scrollToTop(LogsFragment logsFragment) {
+ logsFragment.binding.appBar.setExpanded(true, true);
+ if (layoutManager.findFirstVisibleItemPosition() > SCROLL_THRESHOLD) {
+ binding.recyclerView.scrollToPosition(0);
+ } else {
+ binding.recyclerView.smoothScrollToPosition(0);
+ }
+ }
+
+ public void scrollToBottom(LogsFragment logsFragment) {
+ logsFragment.binding.appBar.setExpanded(false, true);
+ var end = Math.max(adaptor.getItemCount() - 1, 0);
+ if (adaptor.getItemCount() - layoutManager.findLastVisibleItemPosition() > SCROLL_THRESHOLD) {
+ binding.recyclerView.scrollToPosition(end);
+ } else {
+ binding.recyclerView.smoothScrollToPosition(end);
+ }
+ }
+
+ void attachListeners() {
+ var parent = getParentFragment();
+ if (parent instanceof LogsFragment) {
+ var logsFragment = (LogsFragment) parent;
+ logsFragment.binding.appBar.setLifted(!binding.recyclerView.getBorderViewDelegate().isShowingTopBorder());
+ binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> logsFragment.binding.appBar.setLifted(!top));
+ logsFragment.setOptionsItemSelectListener(item -> {
+ int itemId = item.getItemId();
+ if (itemId == R.id.menu_scroll_top) {
+ scrollToTop(logsFragment);
+ } else if (itemId == R.id.menu_scroll_down) {
+ scrollToBottom(logsFragment);
+ } else if (itemId == R.id.menu_clear) {
+ if (ConfigManager.clearLogs(verbose)) {
+ logsFragment.showHint(R.string.logs_cleared, true);
+ adaptor.fullRefresh();
+ } else {
+ logsFragment.showHint(R.string.logs_clear_failed_2, true);
+ }
+ return true;
+ }
+ return false;
+ });
+
+ View.OnClickListener l = v -> scrollToTop(logsFragment);
+ logsFragment.binding.clickView.setOnClickListener(l);
+ logsFragment.binding.toolbar.setOnClickListener(l);
+ }
+ }
+
+ void detachListeners() {
+ binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener(null);
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ attachListeners();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ adaptor.refresh();
+ attachListeners();
+ }
+
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ detachListeners();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ detachListeners();
+ }
}
- @Override
- public void onViewStateRestored(@Nullable Bundle savedInstanceState) {
- if (savedInstanceState != null) {
- var tabPosition = savedInstanceState.getInt(LogsFragment.class.getName() + "." + "tab", 0);
- if (tabPosition < binding.slidingTabs.getTabCount())
- binding.slidingTabs.selectTab(binding.slidingTabs.getTabAt(tabPosition));
+ public static class UnwrapLogFragment extends LogFragment {
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ var root = super.onCreateView(inflater, container, savedInstanceState);
+ binding.swipeRefreshLayout.removeView(binding.recyclerView);
+ HorizontalScrollView horizontalScrollView = new HorizontalScrollView(getContext());
+ horizontalScrollView.setFillViewport(true);
+ horizontalScrollView.setHorizontalScrollBarEnabled(false);
+ binding.swipeRefreshLayout.addView(horizontalScrollView);
+ horizontalScrollView.addView(binding.recyclerView);
+ binding.recyclerView.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT;
+ return root;
+ }
+
+ @Override
+ protected LogAdaptor createAdaptor() {
+ return new LogAdaptor() {
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ super.onBindViewHolder(holder, position);
+ holder.item.measure(0, 0);
+ }
+ };
+ }
+ }
+
+ class LogPageAdapter extends FragmentStateAdapter {
+
+ public LogPageAdapter(@NonNull Fragment fragment) {
+ super(fragment);
+ }
+
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ var bundle = new Bundle();
+ bundle.putBoolean("verbose", verbose(position));
+ var f = getItemViewType(position) == 0 ? new LogFragment() : new UnwrapLogFragment();
+ f.setArguments(bundle);
+ return f;
+ }
+
+ @Override
+ public int getItemCount() {
+ return 2;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return verbose(position) ? R.string.nav_item_logs_lsp : R.string.nav_item_logs_module;
+ }
+
+ @Override
+ public boolean containsItem(long itemId) {
+ return itemId == R.string.nav_item_logs_lsp || itemId == R.string.nav_item_logs_module;
+ }
+
+ public boolean verbose(int position) {
+ return position != 0;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return wordWrap.isChecked() ? 0 : 1;
+ }
+
+ public void refresh() {
+ runOnUiThread(this::notifyDataSetChanged);
}
- super.onViewStateRestored(savedInstanceState);
}
}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java
index 5719ce5f..6f7b61f9 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/ModulesFragment.java
@@ -46,7 +46,6 @@ import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
-import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -65,7 +64,6 @@ import com.bumptech.glide.request.transition.Transition;
import com.google.android.material.behavior.HideBottomViewOnScrollBehavior;
import com.google.android.material.checkbox.MaterialCheckBox;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
-import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
@@ -74,10 +72,9 @@ import org.lsposed.manager.App;
import org.lsposed.manager.ConfigManager;
import org.lsposed.manager.R;
import org.lsposed.manager.adapters.AppHelper;
-import org.lsposed.manager.databinding.DialogRecyclerviewBinding;
+import org.lsposed.manager.databinding.SwiperefreshRecyclerviewBinding;
import org.lsposed.manager.databinding.FragmentPagerBinding;
import org.lsposed.manager.databinding.ItemModuleBinding;
-import org.lsposed.manager.databinding.ItemRepoRecyclerviewBinding;
import org.lsposed.manager.repo.RepoLoader;
import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder;
import org.lsposed.manager.ui.widget.EmptyStateRecyclerView;
@@ -94,7 +91,7 @@ import java.util.stream.IntStream;
import rikka.core.util.ResourceUtils;
import rikka.recyclerview.RecyclerViewKt;
-public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleListener {
+public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleListener, RepoLoader.RepoListener {
private static final PackageManager pm = App.getInstance().getPackageManager();
private static final ModuleUtil moduleUtil = ModuleUtil.getInstance();
private static final RepoLoader repoLoader = RepoLoader.getInstance();
@@ -103,14 +100,7 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
protected SearchView searchView;
private SearchView.OnQueryTextListener searchListener;
- private final ArrayList adapters = new ArrayList<>();
-
- private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() {
- @Override
- public void onChanged() {
- updateProgress();
- }
- };
+ final ArrayList adapters = new ArrayList<>();
private ModuleUtil.InstalledModule selectedModule;
@@ -125,8 +115,8 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
}
@Override
- public boolean onQueryTextChange(String newText) {
- adapters.forEach(adapter -> adapter.getFilter().filter(newText));
+ public boolean onQueryTextChange(String query) {
+ adapters.forEach(adapter -> adapter.getFilter().filter(query));
return false;
}
};
@@ -137,7 +127,6 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
adapter.setHasStableIds(true);
adapter.setStateRestorationPolicy(PREVENT_WHEN_EMPTY);
adapters.add(adapter);
- adapter.registerAdapterDataObserver(observer);
}
}
}
@@ -154,29 +143,16 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
}
}
- private void updateProgress() {
- if (binding != null) {
- var position = binding.viewPager.getCurrentItem();
- binding.progress.setVisibility(adapters.get(position).isLoaded ? View.GONE : View.VISIBLE);
- }
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- moduleUtil.addListener(this);
- }
-
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
binding = FragmentPagerBinding.inflate(inflater, container, false);
- setupToolbar(binding.toolbar, R.string.Modules, R.menu.menu_modules);
+ binding.appBar.setLiftable(true);
+ setupToolbar(binding.toolbar, binding.clickView, R.string.Modules, R.menu.menu_modules);
binding.viewPager.setAdapter(new PagerAdapter(this));
binding.viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
- updateProgress();
showFab();
}
});
@@ -204,31 +180,18 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
binding.tabLayout.setVisibility(View.GONE);
}
binding.fab.setOnClickListener(v -> {
- var pickAdaptor = new ModuleAdapter(adapters.get(binding.viewPager.getCurrentItem()).getUser(), true);
- var position = binding.viewPager.getCurrentItem();
- var user = adapters.get(position).getUser();
- var binding = DialogRecyclerviewBinding.inflate(getLayoutInflater());
- binding.list.setAdapter(pickAdaptor);
- binding.list.setLayoutManager(new LinearLayoutManager(requireActivity()));
- pickAdaptor.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
- @Override
- public void onChanged() {
- binding.progress.setVisibility(pickAdaptor.isLoaded() ? View.GONE : View.VISIBLE);
- }
- });
- pickAdaptor.refresh();
- var dialog = new BlurBehindDialogBuilder(requireActivity())
- .setTitle(getString(R.string.install_to_user, user.name))
- .setView(binding.getRoot())
- .setNegativeButton(android.R.string.cancel, null)
- .show();
- pickAdaptor.setOnPickListener(picked -> {
- var module = (ModuleUtil.InstalledModule) picked.getTag();
- installModuleToUser(module, user);
- dialog.dismiss();
- });
+ var bundle = new Bundle();
+ var user = adapters.get(binding.viewPager.getCurrentItem()).getUser();
+ bundle.putParcelable("userInfo", user);
+ var f = new RecyclerViewDialogFragment();
+ f.setArguments(bundle);
+ f.show(getChildFragmentManager(), "install_to_user" + user.id);
});
+ moduleUtil.addListener(this);
+ repoLoader.addListener(this);
+ updateModuleSummary();
+
return binding.getRoot();
}
@@ -236,6 +199,16 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
public void onPrepareOptionsMenu(Menu menu) {
searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
searchView.setOnQueryTextListener(searchListener);
+ searchView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View arg0) {
+ binding.appBar.setExpanded(false, true);
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v) {
+ }
+ });
}
@Override
@@ -245,31 +218,30 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
}
@Override
- public void onDestroy() {
- super.onDestroy();
- moduleUtil.removeListener(this);
- }
-
- @Override
- public void onSingleInstalledModuleReloaded(ModuleUtil.InstalledModule module) {
+ public void onSingleModuleReloaded(ModuleUtil.InstalledModule module) {
adapters.forEach(ModuleAdapter::refresh);
}
@Override
public void onModulesReloaded() {
adapters.forEach(ModuleAdapter::refresh);
+ updateModuleSummary();
}
@Override
- public boolean onOptionsItemSelected(@NonNull MenuItem item) {
- int itemId = item.getItemId();
- if (itemId == R.id.menu_refresh) {
- adapters.forEach(adapter -> adapter.refresh(true));
- }
- return super.onOptionsItemSelected(item);
+ public void onRepoLoaded() {
+ adapters.forEach(ModuleAdapter::refresh);
}
- private void installModuleToUser(ModuleUtil.InstalledModule module, UserInfo user) {
+ private void updateModuleSummary() {
+ var moduleCount = moduleUtil.getEnabledModulesCount();
+ runOnUiThread(() -> {
+ binding.toolbar.setSubtitle(moduleCount == -1 ? getString(R.string.loading) : getResources().getQuantityString(R.plurals.modules_enabled_count, moduleCount, moduleCount));
+ binding.toolbarLayout.setSubtitle(binding.toolbar.getSubtitle());
+ });
+ }
+
+ void installModuleToUser(ModuleUtil.InstalledModule module, UserInfo user) {
new BlurBehindDialogBuilder(requireActivity())
.setTitle(getString(R.string.install_to_user, user.name))
.setMessage(getString(R.string.install_to_user_message, module.getAppName(), user.name))
@@ -279,11 +251,7 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
String text = success ?
getString(R.string.module_installed, module.getAppName(), user.name) :
getString(R.string.module_install_failed);
- if (binding != null && isResumed()) {
- Snackbar.make(binding.snackbar, text, Snackbar.LENGTH_LONG).show();
- } else {
- Toast.makeText(App.getInstance(), text, Toast.LENGTH_LONG).show();
- }
+ showHint(text, false);
if (success)
moduleUtil.reloadSingleModule(module.packageName, user.id);
}))
@@ -305,8 +273,6 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
Intent intent = AppHelper.getSettingsIntent(packageName, selectedModule.userId);
if (intent != null) {
ConfigManager.startActivityAsUserWithFeature(intent, selectedModule.userId);
- } else {
- Snackbar.make(binding.snackbar, R.string.module_no_ui, Snackbar.LENGTH_LONG).show();
}
return true;
} else if (itemId == R.id.menu_other_app) {
@@ -326,11 +292,7 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
runAsync(() -> {
boolean success = ConfigManager.uninstallPackage(selectedModule.packageName, selectedModule.userId);
String text = success ? getString(R.string.module_uninstalled, selectedModule.getAppName()) : getString(R.string.module_uninstall_failed);
- if (binding != null && isResumed()) {
- Snackbar.make(binding.snackbar, text, Snackbar.LENGTH_LONG).show();
- } else {
- Toast.makeText(App.getInstance(), text, Toast.LENGTH_LONG).show();
- }
+ showHint(text, false);
if (success)
moduleUtil.reloadSingleModule(selectedModule.packageName, selectedModule.userId);
}))
@@ -341,7 +303,7 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
getNavController().navigate(ModulesFragmentDirections.actionModulesFragmentToRepoItemFragment(selectedModule.packageName, selectedModule.getAppName()));
return true;
} else if (itemId == R.id.menu_compile_speed) {
- CompileDialogFragment.speed(getChildFragmentManager(), selectedModule.pkg.applicationInfo, binding.snackbar);
+ CompileDialogFragment.speed(getChildFragmentManager(), selectedModule.pkg.applicationInfo);
}
return super.onContextItemSelected(item);
}
@@ -349,12 +311,33 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
@Override
public void onDestroyView() {
super.onDestroyView();
-
moduleUtil.removeListener(this);
+ repoLoader.removeListener(this);
binding = null;
}
public static class ModuleListFragment extends Fragment {
+ public SwiperefreshRecyclerviewBinding binding;
+ private ModuleAdapter adapter;
+ private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() {
+ @Override
+ public void onChanged() {
+ binding.swipeRefreshLayout.setRefreshing(!adapter.isLoaded());
+ }
+ };
+
+ private final View.OnAttachStateChangeListener searchViewLocker = new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View v) {
+ binding.recyclerView.setNestedScrollingEnabled(false);
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v) {
+ binding.recyclerView.setNestedScrollingEnabled(true);
+ }
+ };
+
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
@@ -364,13 +347,75 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
return null;
}
int position = arguments.getInt("position");
- ItemRepoRecyclerviewBinding binding = ItemRepoRecyclerviewBinding.inflate(getLayoutInflater(), container, false);
- binding.recyclerView.setAdapter(fragment.adapters.get(position));
- RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(requireActivity());
- binding.recyclerView.setLayoutManager(layoutManager);
+ binding = SwiperefreshRecyclerviewBinding.inflate(getLayoutInflater(), container, false);
+ adapter = fragment.adapters.get(position);
+ binding.recyclerView.setAdapter(adapter);
+ binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));
+ binding.swipeRefreshLayout.setOnRefreshListener(adapter::fullRefresh);
+ binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset());
RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true);
+ adapter.registerAdapterDataObserver(observer);
return binding.getRoot();
}
+
+ void attachListeners() {
+ var parent = getParentFragment();
+ if (parent instanceof ModulesFragment) {
+ var moduleFragment = (ModulesFragment) parent;
+ binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> moduleFragment.binding.appBar.setLifted(!top));
+ moduleFragment.binding.appBar.setLifted(!binding.recyclerView.getBorderViewDelegate().isShowingTopBorder());
+ moduleFragment.searchView.addOnAttachStateChangeListener(searchViewLocker);
+ binding.recyclerView.setNestedScrollingEnabled(moduleFragment.searchView.isIconified());
+ View.OnClickListener l = v -> {
+ if (moduleFragment.searchView.isIconified()) {
+ binding.recyclerView.smoothScrollToPosition(0);
+ moduleFragment.binding.appBar.setExpanded(true, true);
+ }
+ };
+ moduleFragment.binding.clickView.setOnClickListener(l);
+ moduleFragment.binding.toolbar.setOnClickListener(l);
+ }
+ }
+
+ void detachListeners() {
+ binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener(null);
+ var parent = getParentFragment();
+ if (parent instanceof ModulesFragment) {
+ var moduleFragment = (ModulesFragment) parent;
+ moduleFragment.searchView.removeOnAttachStateChangeListener(searchViewLocker);
+ binding.recyclerView.setNestedScrollingEnabled(true);
+ }
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ attachListeners();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ attachListeners();
+ }
+
+ @Override
+ public void onDestroyView() {
+ adapter.unregisterAdapterDataObserver(observer);
+ super.onDestroyView();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ detachListeners();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ detachListeners();
+ }
}
private class PagerAdapter extends FragmentStateAdapter {
@@ -400,7 +445,11 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
}
}
- private class ModuleAdapter extends EmptyStateRecyclerView.EmptyStateAdapter implements Filterable {
+ ModuleAdapter createPickModuleAdapter(UserInfo userInfo) {
+ return new ModuleAdapter(userInfo, true);
+ }
+
+ class ModuleAdapter extends EmptyStateRecyclerView.EmptyStateAdapter implements Filterable {
private List searchList = new ArrayList<>();
private List showList = new ArrayList<>();
private final UserInfo user;
@@ -487,22 +536,20 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
}
sb.setSpan(foregroundColorSpan, sb.length() - warningText.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
}
- if (repoLoader.isRepoLoaded()) {
- var ver = repoLoader.getModuleLatestVersion(item.packageName);
- if (ver != null && ver.upgradable(item.versionCode, item.versionName)) {
- if (warningText != null) sb.append("\n");
- String recommended = getString(R.string.update_available, ver.versionName);
- sb.append(recommended);
- final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(requireActivity().getTheme(), androidx.appcompat.R.attr.colorAccent));
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- final TypefaceSpan typefaceSpan = new TypefaceSpan(Typeface.create("sans-serif-medium", Typeface.NORMAL));
- sb.setSpan(typefaceSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
- } else {
- final StyleSpan styleSpan = new StyleSpan(Typeface.BOLD);
- sb.setSpan(styleSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
- }
- sb.setSpan(foregroundColorSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ var ver = repoLoader.getModuleLatestVersion(item.packageName);
+ if (ver != null && ver.upgradable(item.versionCode, item.versionName)) {
+ if (warningText != null) sb.append("\n");
+ String recommended = getString(R.string.update_available, ver.versionName);
+ sb.append(recommended);
+ final ForegroundColorSpan foregroundColorSpan = new ForegroundColorSpan(ResourceUtils.resolveColor(requireActivity().getTheme(), androidx.appcompat.R.attr.colorAccent));
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ final TypefaceSpan typefaceSpan = new TypefaceSpan(Typeface.create("sans-serif-medium", Typeface.NORMAL));
+ sb.setSpan(typefaceSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
+ } else {
+ final StyleSpan styleSpan = new StyleSpan(Typeface.BOLD);
+ sb.setSpan(styleSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
}
+ sb.setSpan(foregroundColorSpan, sb.length() - recommended.length(), sb.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
}
if (sb.length() == 0) {
holder.hint.setVisibility(View.GONE);
@@ -515,7 +562,6 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
holder.root.setAlpha(moduleUtil.isModuleEnabled(item.packageName) ? 1.0f : .5f);
holder.itemView.setOnClickListener(v -> {
searchView.clearFocus();
- searchView.onActionViewCollapsed();
getNavController().navigate(ModulesFragmentDirections.actionModulesFragmentToAppListFragment(item.packageName, item.userId));
});
holder.itemView.setOnLongClickListener(v -> {
@@ -585,12 +631,15 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
}
public void refresh() {
- refresh(false);
+ runAsync(reloadModules);
}
- public void refresh(boolean force) {
- if (force) runAsync(moduleUtil::reloadInstalledModules);
- runAsync(reloadModules);
+ public void fullRefresh() {
+ runAsync(() -> {
+ setLoaded(false);
+ moduleUtil.reloadInstalledModules();
+ refresh();
+ });
}
private final Runnable reloadModules = () -> {
@@ -634,7 +683,7 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
});
String queryStr = searchView != null ? searchView.getQuery().toString() : "";
searchList = tmpList;
- runOnUiThread(() -> getFilter().filter(queryStr, count -> setLoaded(true)));
+ runOnUiThread(() -> getFilter().filter(queryStr));
};
@SuppressLint("NotifyDataSetChanged")
@@ -647,7 +696,7 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
@Override
public boolean isLoaded() {
- return isLoaded;
+ return isLoaded && moduleUtil.isModulesLoaded();
}
class ViewHolder extends RecyclerView.ViewHolder {
@@ -681,16 +730,12 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
List filtered = new ArrayList<>();
- if (constraint.toString().isEmpty()) {
- filtered.addAll(searchList);
- } else {
- String filter = constraint.toString().toLowerCase();
- for (ModuleUtil.InstalledModule info : searchList) {
- if (lowercaseContains(info.getAppName(), filter) ||
- lowercaseContains(info.packageName, filter) ||
- lowercaseContains(info.getDescription(), filter)) {
- filtered.add(info);
- }
+ String filter = constraint.toString().toLowerCase();
+ for (ModuleUtil.InstalledModule info : searchList) {
+ if (lowercaseContains(info.getAppName(), filter) ||
+ lowercaseContains(info.packageName, filter) ||
+ lowercaseContains(info.getDescription(), filter)) {
+ filtered.add(info);
}
}
filterResults.values = filtered;
@@ -702,6 +747,7 @@ public class ModulesFragment extends BaseFragment implements ModuleUtil.ModuleLi
protected void publishResults(CharSequence constraint, FilterResults results) {
//noinspection unchecked
showList = (List) results.values;
+ setLoaded(true);
}
}
}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RecyclerViewDialogFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RecyclerViewDialogFragment.java
new file mode 100644
index 00000000..d9d69eaa
--- /dev/null
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/RecyclerViewDialogFragment.java
@@ -0,0 +1,85 @@
+/*
+ * This file is part of LSPosed.
+ *
+ * LSPosed is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LSPosed is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LSPosed. If not, see .
+ *
+ * Copyright (C) 2021 LSPosed Contributors
+ */
+
+package org.lsposed.manager.ui.fragment;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatDialogFragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import org.lsposed.lspd.models.UserInfo;
+import org.lsposed.manager.R;
+import org.lsposed.manager.databinding.DialogTitleBinding;
+import org.lsposed.manager.databinding.SwiperefreshRecyclerviewBinding;
+import org.lsposed.manager.ui.dialog.BlurBehindDialogBuilder;
+import org.lsposed.manager.util.ModuleUtil;
+
+public class RecyclerViewDialogFragment extends AppCompatDialogFragment {
+ @Override
+ @NonNull
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ var parent = getParentFragment();
+ var arguments = getArguments();
+ if (!(parent instanceof ModulesFragment) || arguments == null) {
+ throw new IllegalStateException();
+ }
+ var modulesFragment = (ModulesFragment) parent;
+ var user = (UserInfo) arguments.getParcelable("userInfo");
+
+ var pickAdaptor = modulesFragment.createPickModuleAdapter(user);
+ var binding = SwiperefreshRecyclerviewBinding.inflate(LayoutInflater.from(requireActivity()), null, false);
+
+ binding.recyclerView.setAdapter(pickAdaptor);
+ binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));
+ pickAdaptor.registerAdapterDataObserver(new RecyclerView.AdapterDataObserver() {
+ @Override
+ public void onChanged() {
+ binding.swipeRefreshLayout.setRefreshing(!pickAdaptor.isLoaded());
+ }
+ });
+ binding.swipeRefreshLayout.setOnRefreshListener(pickAdaptor::fullRefresh);
+ pickAdaptor.refresh();
+ var title = DialogTitleBinding.inflate(getLayoutInflater()).getRoot();
+ title.setText(getString(R.string.install_to_user, user.name));
+ var dialog = new BlurBehindDialogBuilder(requireActivity())
+ .setCustomTitle(title)
+ .setView(binding.getRoot())
+ .setNegativeButton(android.R.string.cancel, null)
+ .create();
+ title.setOnClickListener(s -> binding.recyclerView.smoothScrollToPosition(0));
+ pickAdaptor.setOnPickListener(picked -> {
+ var module = (ModuleUtil.InstalledModule) picked.getTag();
+ modulesFragment.installModuleToUser(module, user);
+ dialog.dismiss();
+ });
+ return dialog;
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ }
+}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoFragment.java
index b48a23e5..f7d7b164 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoFragment.java
@@ -44,26 +44,23 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SearchView;
import androidx.constraintlayout.widget.ConstraintLayout;
-import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
-import com.google.android.material.snackbar.Snackbar;
-
import org.lsposed.manager.App;
import org.lsposed.manager.R;
import org.lsposed.manager.databinding.FragmentRepoBinding;
import org.lsposed.manager.databinding.ItemOnlinemoduleBinding;
import org.lsposed.manager.repo.RepoLoader;
import org.lsposed.manager.repo.model.OnlineModule;
+import org.lsposed.manager.ui.widget.EmptyStateRecyclerView;
import org.lsposed.manager.util.ModuleUtil;
-import org.lsposed.manager.util.SimpleStatefulAdaptor;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
-import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
import java.util.stream.Collectors;
@@ -71,7 +68,7 @@ import rikka.core.util.LabelComparator;
import rikka.core.util.ResourceUtils;
import rikka.recyclerview.RecyclerViewKt;
-public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
+public class RepoFragment extends BaseFragment implements RepoLoader.RepoListener, ModuleUtil.ModuleListener {
protected FragmentRepoBinding binding;
protected SearchView searchView;
private SearchView.OnQueryTextListener mSearchListener;
@@ -79,7 +76,14 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
private boolean preLoadWebview = true;
private final RepoLoader repoLoader = RepoLoader.getInstance();
+ private final ModuleUtil moduleUtil = ModuleUtil.getInstance();
private RepoAdapter adapter;
+ private final RecyclerView.AdapterDataObserver observer = new RecyclerView.AdapterDataObserver() {
+ @Override
+ public void onChanged() {
+ binding.swipeRefreshLayout.setRefreshing(!adapter.isLoaded());
+ }
+ };
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
@@ -103,33 +107,78 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
binding = FragmentRepoBinding.inflate(getLayoutInflater(), container, false);
- setupToolbar(binding.toolbar, R.string.module_repo, R.menu.menu_repo);
+ binding.appBar.setLiftable(true);
+ binding.recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> binding.appBar.setLifted(!top));
+ setupToolbar(binding.toolbar, binding.clickView, R.string.module_repo, R.menu.menu_repo);
adapter = new RepoAdapter();
adapter.setHasStableIds(true);
+ adapter.registerAdapterDataObserver(observer);
binding.recyclerView.setAdapter(adapter);
binding.recyclerView.setHasFixedSize(true);
binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));
RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true);
- binding.progress.setVisibilityAfterHide(View.GONE);
+ binding.swipeRefreshLayout.setOnRefreshListener(adapter::fullRefresh);
+ binding.swipeRefreshLayout.setProgressViewEndTarget(true, binding.swipeRefreshLayout.getProgressViewEndOffset());
+ View.OnClickListener l = v -> {
+ if (searchView.isIconified()) {
+ binding.recyclerView.smoothScrollToPosition(0);
+ binding.appBar.setExpanded(true, true);
+ }
+ };
+ binding.toolbar.setOnClickListener(l);
+ binding.clickView.setOnClickListener(l);
repoLoader.addListener(this);
-
- /*
- CollapsingToolbarLayout consumes window insets, causing child views not
- receiving window insets.
- See https://github.com/material-components/material-components-android/issues/1310
-
- Insets can be handled by RikkaX Insets, so we can manually set
- OnApplyWindowInsetsListener to null.
- */
-
- binding.collapsingToolbarLayout.setOnApplyWindowInsetsListener(null);
+ moduleUtil.addListener(this);
+ updateRepoSummary();
return binding.getRoot();
}
+ private void updateRepoSummary() {
+ final int[] count = new int[]{0};
+ HashSet processedModules = new HashSet<>();
+ var modules = moduleUtil.getModules();
+ if (modules != null && repoLoader.isRepoLoaded()) {
+ modules.forEach((k, v) -> {
+ if (!processedModules.contains(k.first)) {
+ var ver = repoLoader.getModuleLatestVersion(k.first);
+ if (ver != null && ver.upgradable(v.versionCode, v.versionName)) {
+ ++count[0];
+ }
+ processedModules.add(k.first);
+ }
+ }
+ );
+ } else {
+ count[0] = -1;
+ }
+ runOnUiThread(() -> {
+ if (count[0] > 0) {
+ binding.toolbar.setSubtitle(getResources().getQuantityString(R.plurals.module_repo_upgradable, count[0], count[0]));
+ } else if (count[0] == 0) {
+ binding.toolbar.setSubtitle(getResources().getString(R.string.module_repo_up_to_date));
+ } else {
+ binding.toolbar.setSubtitle(getResources().getString(R.string.loading));
+ }
+ binding.toolbarLayout.setSubtitle(binding.toolbar.getSubtitle());
+ });
+ }
+
@Override
public void onPrepareOptionsMenu(Menu menu) {
searchView = (SearchView) menu.findItem(R.id.menu_search).getActionView();
searchView.setOnQueryTextListener(mSearchListener);
+ searchView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(View arg0) {
+ binding.appBar.setExpanded(false, true);
+ binding.recyclerView.setNestedScrollingEnabled(false);
+ }
+
+ @Override
+ public void onViewDetachedFromWindow(View v) {
+ binding.recyclerView.setNestedScrollingEnabled(true);
+ }
+ });
int sort = App.getPreferences().getInt("repo_sort", 0);
if (sort == 0) {
menu.findItem(R.id.item_sort_by_name).setChecked(true);
@@ -144,13 +193,15 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
mHandler.removeCallbacksAndMessages(null);
repoLoader.removeListener(this);
+ moduleUtil.removeListener(this);
+ adapter.unregisterAdapterDataObserver(observer);
binding = null;
}
@Override
public void onResume() {
super.onResume();
- adapter.initData();
+ adapter.refresh();
if (preLoadWebview) {
mHandler.postDelayed(() -> new WebView(requireContext()), 500);
preLoadWebview = false;
@@ -158,41 +209,41 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
}
@Override
- public void repoLoaded() {
- runOnUiThread(() -> {
- binding.progress.hide();
- adapter.setData(repoLoader.getOnlineModules());
- });
+ public void onRepoLoaded() {
+ adapter.refresh();
+ updateRepoSummary();
}
@Override
public void onThrowable(Throwable t) {
- if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
- Snackbar.make(binding.snackbar, getString(R.string.repo_load_failed, t.getLocalizedMessage()), Snackbar.LENGTH_SHORT).show();
- }
+ showHint(getString(R.string.repo_load_failed, t.getLocalizedMessage()), true);
+ updateRepoSummary();
+ }
+
+ @Override
+ public void onModulesReloaded() {
+ updateRepoSummary();
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
int itemId = item.getItemId();
- if (itemId == R.id.menu_refresh) {
- binding.progress.show();
- repoLoader.loadRemoteData();
- } else if (itemId == R.id.item_sort_by_name) {
+ if (itemId == R.id.item_sort_by_name) {
item.setChecked(true);
App.getPreferences().edit().putInt("repo_sort", 0).apply();
- adapter.setData(repoLoader.getOnlineModules());
+ adapter.refresh();
} else if (itemId == R.id.item_sort_by_update_time) {
item.setChecked(true);
App.getPreferences().edit().putInt("repo_sort", 1).apply();
- adapter.setData(repoLoader.getOnlineModules());
+ adapter.refresh();
}
return super.onOptionsItemSelected(item);
}
- private class RepoAdapter extends SimpleStatefulAdaptor implements Filterable {
+ private class RepoAdapter extends EmptyStateRecyclerView.EmptyStateAdapter implements Filterable {
private List fullList, showList;
private final LabelComparator labelComparator = new LabelComparator();
+ private boolean isLoaded = false;
RepoAdapter() {
fullList = showList = Collections.emptyList();
@@ -219,7 +270,7 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
holder.appDescription.setVisibility(View.VISIBLE);
holder.appDescription.setText(sb);
sb = new SpannableStringBuilder();
- ModuleUtil.InstalledModule installedModule = ModuleUtil.getInstance().getModule(module.getName());
+ ModuleUtil.InstalledModule installedModule = moduleUtil.getModule(module.getName());
if (installedModule != null) {
var ver = repoLoader.getModuleLatestVersion(installedModule.packageName);
if (ver != null && ver.upgradable(installedModule.versionCode, installedModule.versionName)) {
@@ -245,9 +296,9 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
holder.itemView.setOnClickListener(v -> {
searchView.clearFocus();
- searchView.onActionViewCollapsed();
getNavController().navigate(RepoFragmentDirections.actionRepoFragmentToRepoItemFragment(module.getName(), module.getDescription()));
});
+ holder.itemView.setTooltipText(module.getDescription());
}
@Override
@@ -255,27 +306,37 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
return showList.size();
}
- public void setData(Collection modules) {
- fullList = new ArrayList<>(modules);
- fullList = fullList.stream().filter((onlineModule -> !onlineModule.isHide() && !onlineModule.getReleases().isEmpty())).collect(Collectors.toList());
- int sort = App.getPreferences().getInt("repo_sort", 0);
- if (sort == 0) {
- fullList.sort((o1, o2) -> labelComparator.compare(o1.getDescription(), o2.getDescription()));
- } else if (sort == 1) {
- fullList.sort(Collections.reverseOrder(Comparator.comparing(o -> Instant.parse(o.getReleases().get(0).getUpdatedAt()))));
- }
- String queryStr = searchView != null ? searchView.getQuery().toString() : "";
- requireActivity().runOnUiThread(() -> getFilter().filter(queryStr));
+ private void setLoaded(boolean isLoaded) {
+ this.isLoaded = isLoaded;
+ runOnUiThread(this::notifyDataSetChanged);
}
- public void initData() {
- Collection modules = repoLoader.getOnlineModules();
- if (!repoLoader.isRepoLoaded()) {
- binding.progress.show();
+ public void setData(Collection modules) {
+ if (modules == null) return;
+ setLoaded(false);
+ int sort = App.getPreferences().getInt("repo_sort", 0);
+ fullList = modules.parallelStream().filter((onlineModule -> !onlineModule.isHide() && !onlineModule.getReleases().isEmpty()))
+ .sorted((a, b) -> {
+ if (sort == 0) {
+ return labelComparator.compare(a.getDescription(), b.getDescription());
+ } else {
+ return Instant.parse(b.getReleases().get(0).getUpdatedAt()).compareTo(Instant.parse(a.getReleases().get(0).getUpdatedAt()));
+ }
+ }).collect(Collectors.toList());
+ String queryStr = searchView != null ? searchView.getQuery().toString() : "";
+ runOnUiThread(() -> getFilter().filter(queryStr));
+ }
+
+ public void fullRefresh() {
+ runAsync(() -> {
+ setLoaded(false);
repoLoader.loadRemoteData();
- } else {
- adapter.setData(modules);
- }
+ refresh();
+ });
+ }
+
+ public void refresh() {
+ runAsync(() -> adapter.setData(repoLoader.getOnlineModules()));
}
@Override
@@ -288,6 +349,11 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
return new RepoAdapter.ModuleFilter();
}
+ @Override
+ public boolean isLoaded() {
+ return isLoaded && repoLoader.isRepoLoaded();
+ }
+
class ViewHolder extends RecyclerView.ViewHolder {
ConstraintLayout root;
TextView appName;
@@ -311,26 +377,26 @@ public class RepoFragment extends BaseFragment implements RepoLoader.Listener {
@Override
protected FilterResults performFiltering(CharSequence constraint) {
- if (constraint.toString().isEmpty()) {
- showList = fullList;
- } else {
- ArrayList filtered = new ArrayList<>();
- String filter = constraint.toString().toLowerCase();
- for (OnlineModule info : fullList) {
- if (lowercaseContains(info.getDescription(), filter) ||
- lowercaseContains(info.getName(), filter) ||
- lowercaseContains(info.getSummary(), filter)) {
- filtered.add(info);
- }
+ FilterResults filterResults = new FilterResults();
+ ArrayList filtered = new ArrayList<>();
+ String filter = constraint.toString().toLowerCase();
+ for (OnlineModule info : fullList) {
+ if (lowercaseContains(info.getDescription(), filter) ||
+ lowercaseContains(info.getName(), filter) ||
+ lowercaseContains(info.getSummary(), filter)) {
+ filtered.add(info);
}
- showList = filtered;
}
- return null;
+ filterResults.values = filtered;
+ filterResults.count = filtered.size();
+ return filterResults;
}
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
- notifyDataSetChanged();
+ //noinspection unchecked
+ showList = (List) results.values;
+ setLoaded(true);
}
}
}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java
index b57c14bd..5137d399 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/RepoItemFragment.java
@@ -19,6 +19,7 @@
package org.lsposed.manager.ui.fragment;
+import android.app.Dialog;
import android.content.res.Resources;
import android.graphics.Color;
import android.os.Bundle;
@@ -36,16 +37,20 @@ import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
+import android.widget.ScrollView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.progressindicator.CircularProgressIndicator;
-import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
@@ -83,13 +88,13 @@ import okhttp3.Request;
import okhttp3.Response;
import rikka.core.util.ResourceUtils;
import rikka.recyclerview.RecyclerViewKt;
-import rikka.widget.borderview.BorderNestedScrollView;
-import rikka.widget.borderview.BorderRecyclerView;
+import rikka.widget.borderview.BorderView;
-public class RepoItemFragment extends BaseFragment implements RepoLoader.Listener {
+public class RepoItemFragment extends BaseFragment implements RepoLoader.RepoListener {
FragmentPagerBinding binding;
- private OnlineModule module;
+ OnlineModule module;
private ReleaseAdapter releaseAdapter;
+ private InformationAdapter informationAdapter;
@Nullable
@Override
@@ -98,9 +103,11 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
if (module == null) return binding.getRoot();
String modulePackageName = module.getName();
String moduleName = module.getDescription();
- setupToolbar(binding.toolbar, moduleName, R.menu.menu_repo_item);
+ binding.appBar.setLiftable(true);
+ setupToolbar(binding.toolbar, binding.clickView, moduleName, R.menu.menu_repo_item);
+ binding.clickView.setTooltipText(moduleName);
binding.toolbar.setSubtitle(modulePackageName);
- binding.viewPager.setAdapter(new PagerAdapter());
+ binding.viewPager.setAdapter(new PagerAdapter(this));
int[] titles = new int[]{R.string.module_readme, R.string.module_releases, R.string.module_information};
new TabLayoutMediator(binding.tabLayout, binding.viewPager, (tab, position) -> tab.setText(titles[position])).attach();
@@ -112,15 +119,13 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
binding.tabLayout.setTabGravity(TabLayout.GRAVITY_FILL);
}
});
-
+ binding.toolbar.setOnClickListener(v -> binding.appBar.setExpanded(true, true));
+ releaseAdapter = new ReleaseAdapter();
+ informationAdapter = new InformationAdapter();
+ RepoLoader.getInstance().addListener(this);
return binding.getRoot();
}
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- }
-
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
RepoLoader.getInstance().addListener(this);
@@ -208,44 +213,35 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
}
@Override
- public void moduleReleasesLoaded(OnlineModule module) {
+ public void onDestroyView() {
+ super.onDestroyView();
+ RepoLoader.getInstance().removeListener(this);
+ binding = null;
+ }
+
+ @Override
+ public void onModuleReleasesLoaded(OnlineModule module) {
this.module = module;
- if (releaseAdapter != null) {
- runAsync(releaseAdapter::loadItems);
- if (isResumed() && module.getReleases().size() == 1) {
- Snackbar.make(binding.snackbar, R.string.module_release_no_more, Snackbar.LENGTH_SHORT).show();
- }
+ runAsync(releaseAdapter::loadItems);
+ if (module.getReleases().size() == 1) {
+ showHint(R.string.module_release_no_more, true);
}
}
@Override
public void onThrowable(Throwable t) {
- if (releaseAdapter != null) {
- runAsync(releaseAdapter::loadItems);
- if (isResumed()) {
- Snackbar.make(binding.snackbar, getString(R.string.repo_load_failed, t.getLocalizedMessage()), Snackbar.LENGTH_SHORT).show();
- }
- }
- }
-
- @Override
- public void onDestroyView() {
- super.onDestroyView();
-
- RepoLoader.getInstance().removeListener(this);
- binding = null;
+ runAsync(releaseAdapter::loadItems);
+ showHint(getString(R.string.repo_load_failed, t.getLocalizedMessage()), true);
}
private class InformationAdapter extends SimpleStatefulAdaptor {
- private final OnlineModule module;
private int rowCount = 0;
private int homepageRow = -1;
private int collaboratorsRow = -1;
private int sourceUrlRow = -1;
- public InformationAdapter(OnlineModule module) {
- this.module = module;
+ public InformationAdapter() {
if (!TextUtils.isEmpty(module.getHomepageUrl())) {
homepageRow = rowCount++;
}
@@ -260,7 +256,7 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
@NonNull
@Override
public InformationAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
- return new InformationAdapter.ViewHolder(ItemRepoTitleDescriptionBinding.inflate(getLayoutInflater(), parent, false));
+ return new ViewHolder(ItemRepoTitleDescriptionBinding.inflate(getLayoutInflater(), parent, false));
}
@Override
@@ -302,7 +298,6 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
NavUtil.startURL(requireActivity(), module.getSourceUrl());
}
});
-
}
@Override
@@ -322,6 +317,27 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
}
}
+ public static class DownloadDialog extends DialogFragment {
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ var args = getArguments();
+ if (args == null) throw new IllegalArgumentException();
+ return new BlurBehindDialogBuilder(requireActivity())
+ .setItems(args.getCharSequenceArray("names"), (dialog, which) -> NavUtil.startURL(requireActivity(), args.getStringArrayList("urls").get(which)))
+ .create();
+ }
+
+ static void create(FragmentManager fm, String[] names, ArrayList urls) {
+ var f = new DownloadDialog();
+ var bundle = new Bundle();
+ bundle.putStringArray("names", names);
+ bundle.putStringArrayList("urls", urls);
+ f.setArguments(bundle);
+ f.show(fm, "download");
+ }
+ }
+
private class ReleaseAdapter extends EmptyStateRecyclerView.EmptyStateAdapter {
private List items = new ArrayList<>();
private final Resources resources = App.getInstance().getResources();
@@ -353,9 +369,9 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
@Override
public ReleaseAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
if (viewType == 0) {
- return new ReleaseAdapter.ReleaseViewHolder(ItemRepoReleaseBinding.inflate(getLayoutInflater(), parent, false));
+ return new ReleaseViewHolder(ItemRepoReleaseBinding.inflate(getLayoutInflater(), parent, false));
} else {
- return new ReleaseAdapter.LoadmoreViewHolder(ItemRepoLoadmoreBinding.inflate(getLayoutInflater(), parent, false));
+ return new LoadmoreViewHolder(ItemRepoLoadmoreBinding.inflate(getLayoutInflater(), parent, false));
}
}
@@ -381,9 +397,7 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
holder.viewAssets.setOnClickListener(v -> {
ArrayList names = new ArrayList<>();
assets.forEach(releaseAsset -> names.add(releaseAsset.getName()));
- new BlurBehindDialogBuilder(requireActivity())
- .setItems(names.toArray(new String[0]), (dialog, which) -> NavUtil.startURL(requireActivity(), assets.get(which).getDownloadUrl()))
- .show();
+ DownloadDialog.create(getChildFragmentManager(), names.toArray(new String[0]), assets.stream().map(ReleaseAsset::getDownloadUrl).collect(Collectors.toCollection(ArrayList::new)));
});
} else {
holder.viewAssets.setVisibility(View.GONE);
@@ -437,38 +451,27 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
}
}
- private class PagerAdapter extends SimpleStatefulAdaptor {
+ private static class PagerAdapter extends FragmentStateAdapter {
+
+ public PagerAdapter(@NonNull Fragment fragment) {
+ super(fragment);
+ }
@NonNull
@Override
- public PagerAdapter.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
- if (viewType == 0) {
- return new PagerAdapter.ReadmeViewHolder(ItemRepoReadmeBinding.inflate(getLayoutInflater(), parent, false));
+ public Fragment createFragment(int position) {
+ Bundle bundle = new Bundle();
+ bundle.putInt("position", position);
+ Fragment f;
+ if (position == 0) {
+ f = new ReadmeFragment();
+ } else if (position == 1) {
+ f = new RecyclerviewFragment();
} else {
- return new PagerAdapter.RecyclerviewBinding(ItemRepoRecyclerviewBinding.inflate(getLayoutInflater(), parent, false));
- }
- }
-
- @Override
- public void onBindViewHolder(@NonNull PagerAdapter.ViewHolder holder, int position) {
- switch (position) {
- case 0:
- if (module != null)
- renderGithubMarkdown(holder.webView, module.getReadmeHTML());
- break;
- case 1:
- case 2:
- RecyclerView.Adapter adapter;
- if (position == 1) {
- adapter = releaseAdapter = new ReleaseAdapter();
- } else {
- adapter = new InformationAdapter(module);
- }
- holder.recyclerView.setAdapter(adapter);
- holder.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));
- RecyclerViewKt.fixEdgeEffect(holder.recyclerView, false, true);
- break;
+ f = new RecyclerviewFragment();
}
+ f.setArguments(bundle);
+ return f;
}
@Override
@@ -481,29 +484,112 @@ public class RepoItemFragment extends BaseFragment implements RepoLoader.Listene
return position == 0 ? 0 : 1;
}
- class ViewHolder extends RecyclerView.ViewHolder {
- WebView webView;
- BorderNestedScrollView scrollView;
- BorderRecyclerView recyclerView;
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+ }
- public ViewHolder(@NonNull View itemView) {
- super(itemView);
+ public static abstract class BorderFragment extends BaseFragment {
+ BorderView borderView;
+
+ void attachListeners() {
+ var parent = getParentFragment();
+ if (parent instanceof RepoItemFragment) {
+ var repoItemFragment = (RepoItemFragment) parent;
+ borderView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> repoItemFragment.binding.appBar.setLifted(!top));
+ repoItemFragment.binding.appBar.setLifted(!borderView.getBorderViewDelegate().isShowingTopBorder());
+ repoItemFragment.binding.toolbar.setOnClickListener(v -> {
+ repoItemFragment.binding.appBar.setExpanded(true, true);
+ scrollToTop();
+ });
}
}
- class ReadmeViewHolder extends PagerAdapter.ViewHolder {
- public ReadmeViewHolder(ItemRepoReadmeBinding binding) {
- super(binding.getRoot());
- webView = binding.readme;
- scrollView = binding.scrollView;
- }
+ abstract void scrollToTop();
+
+ void detachListeners() {
+ borderView.getBorderViewDelegate().setBorderVisibilityChangedListener(null);
}
- class RecyclerviewBinding extends PagerAdapter.ViewHolder {
- public RecyclerviewBinding(ItemRepoRecyclerviewBinding binding) {
- super(binding.getRoot());
- recyclerView = binding.recyclerView;
+ @Override
+ public void onResume() {
+ super.onResume();
+ attachListeners();
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ attachListeners();
+ }
+
+ @Override
+ public void onStop() {
+ super.onStop();
+ detachListeners();
+ }
+
+ @Override
+ public void onPause() {
+ super.onPause();
+ detachListeners();
+ }
+ }
+
+ public static class ReadmeFragment extends BorderFragment {
+ ItemRepoReadmeBinding binding;
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ var parent = getParentFragment();
+ if (!(parent instanceof RepoItemFragment)) {
+ getNavController().navigate(R.id.action_repo_item_fragment_to_repo_fragment);
+ return null;
}
+ var repoItemFragment = (RepoItemFragment) parent;
+ binding = ItemRepoReadmeBinding.inflate(getLayoutInflater(), container, false);
+ repoItemFragment.renderGithubMarkdown(binding.readme, repoItemFragment.module.getReadmeHTML());
+ borderView = binding.scrollView;
+ return binding.getRoot();
+ }
+
+ @Override
+ void scrollToTop() {
+ binding.scrollView.fullScroll(ScrollView.FOCUS_UP);
+ }
+ }
+
+ public static class RecyclerviewFragment extends BorderFragment {
+ ItemRepoRecyclerviewBinding binding;
+ RecyclerView.Adapter> adapter;
+
+ @Override
+ void scrollToTop() {
+ binding.recyclerView.smoothScrollToPosition(0);
+ }
+
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ var arguments = getArguments();
+ var parent = getParentFragment();
+ if (arguments == null || !(parent instanceof RepoItemFragment)) {
+ getNavController().navigate(R.id.action_repo_item_fragment_to_repo_fragment);
+ return null;
+ }
+ var repoItemFragment = (RepoItemFragment) parent;
+ var position = arguments.getInt("position", 0);
+ if (position == 1)
+ adapter = repoItemFragment.releaseAdapter;
+ else if (position == 2)
+ adapter = repoItemFragment.informationAdapter;
+ else return null;
+ binding = ItemRepoRecyclerviewBinding.inflate(getLayoutInflater(), container, false);
+ binding.recyclerView.setAdapter(adapter);
+ binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireActivity()));
+ RecyclerViewKt.fixEdgeEffect(binding.recyclerView, false, true);
+ borderView = binding.recyclerView;
+ return binding.getRoot();
}
}
}
diff --git a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java
index cb9c3a5e..ce5dbda4 100644
--- a/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java
+++ b/app/src/main/java/org/lsposed/manager/ui/fragment/SettingsFragment.java
@@ -29,7 +29,6 @@ import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
-import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
@@ -42,7 +41,6 @@ import androidx.preference.SwitchPreference;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.color.DynamicColors;
-import com.google.android.material.snackbar.Snackbar;
import org.lsposed.manager.App;
import org.lsposed.manager.BuildConfig;
@@ -72,22 +70,19 @@ public class SettingsFragment extends BaseFragment {
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
binding = FragmentSettingsBinding.inflate(inflater, container, false);
- setupToolbar(binding.toolbar, R.string.Settings);
+ binding.appBar.setLiftable(true);
+ setupToolbar(binding.toolbar, binding.clickView, R.string.Settings);
if (savedInstanceState == null) {
getChildFragmentManager().beginTransaction()
.add(R.id.container, new PreferenceFragment()).commitNow();
}
-
- /*
- CollapsingToolbarLayout consumes window insets, causing child views not
- receiving window insets.
- See https://github.com/material-components/material-components-android/issues/1310
-
- Insets can be handled by RikkaX Insets, so we can manually set
- OnApplyWindowInsetsListener to null.
- */
-
- binding.collapsingToolbarLayout.setOnApplyWindowInsetsListener(null);
+ if (ConfigManager.isBinderAlive()) {
+ binding.toolbar.setSubtitle(String.format(Locale.ROOT, "%s (%d) - %s",
+ ConfigManager.getXposedVersionName(), ConfigManager.getXposedVersionCode(), ConfigManager.getApi()));
+ } else {
+ binding.toolbar.setSubtitle(String.format(Locale.ROOT, "%s (%d) - %s",
+ BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE, getString(R.string.not_installed)));
+ }
return binding.getRoot();
}
@@ -109,11 +104,7 @@ public class SettingsFragment extends BaseFragment {
BackupUtils.backup(uri);
} catch (Exception e) {
var text = App.getInstance().getString(R.string.settings_backup_failed2, e.getMessage());
- if (parentFragment != null && parentFragment.binding != null && isResumed()) {
- Snackbar.make(parentFragment.binding.snackbar, text, Snackbar.LENGTH_LONG).show();
- } else {
- Toast.makeText(App.getInstance(), text, Toast.LENGTH_LONG).show();
- }
+ parentFragment.showHint(text, false);
}
});
});
@@ -125,11 +116,7 @@ public class SettingsFragment extends BaseFragment {
BackupUtils.restore(uri);
} catch (Exception e) {
var text = App.getInstance().getString(R.string.settings_restore_failed2, e.getMessage());
- if (parentFragment != null && parentFragment.binding != null && isResumed()) {
- Snackbar.make(parentFragment.binding.snackbar, text, Snackbar.LENGTH_LONG).show();
- } else {
- Toast.makeText(App.getInstance(), text, Toast.LENGTH_LONG).show();
- }
+ parentFragment.showHint(text, false);
}
});
});
@@ -311,6 +298,17 @@ public class SettingsFragment extends BaseFragment {
public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle savedInstanceState) {
BorderRecyclerView recyclerView = (BorderRecyclerView) super.onCreateRecyclerView(inflater, parent, savedInstanceState);
RecyclerViewKt.fixEdgeEffect(recyclerView, false, true);
+ recyclerView.getBorderViewDelegate().setBorderVisibilityChangedListener((top, oldTop, bottom, oldBottom) -> parentFragment.binding.appBar.setLifted(!top));
+ var fragment = getParentFragment();
+ if (fragment instanceof SettingsFragment) {
+ var settingsFragment = (SettingsFragment) fragment;
+ View.OnClickListener l = v -> {
+ settingsFragment.binding.appBar.setExpanded(true, true);
+ recyclerView.smoothScrollToPosition(0);
+ };
+ settingsFragment.binding.toolbar.setOnClickListener(l);
+ settingsFragment.binding.clickView.setOnClickListener(l);
+ }
return recyclerView;
}
diff --git a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java
index d0935159..185c3197 100644
--- a/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java
+++ b/app/src/main/java/org/lsposed/manager/util/ModuleUtil.java
@@ -40,22 +40,27 @@ import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
-import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
public final class ModuleUtil {
// xposedminversion below this
public static int MIN_MODULE_VERSION = 2; // reject modules with
private static ModuleUtil instance = null;
private final PackageManager pm;
- private final List listeners = new CopyOnWriteArrayList<>();
+ private final Set listeners = ConcurrentHashMap.newKeySet();
private HashSet enabledModules = new HashSet<>();
private Map, InstalledModule> installedModules = new HashMap<>();
- private boolean isReloading = false;
+ private boolean modulesLoaded = false;
private ModuleUtil() {
pm = App.getInstance().getPackageManager();
}
+ public boolean isModulesLoaded() {
+ return modulesLoaded;
+ }
+
public static synchronized ModuleUtil getInstance() {
if (instance == null) {
instance = new ModuleUtil();
@@ -76,16 +81,10 @@ public final class ModuleUtil {
return result;
}
- public void reloadInstalledModules() {
- synchronized (this) {
- if (isReloading)
- return;
- isReloading = true;
- }
+ synchronized public void reloadInstalledModules() {
+ modulesLoaded = false;
if (!ConfigManager.isBinderAlive()) {
- synchronized (this) {
- isReloading = false;
- }
+ modulesLoaded = true;
return;
}
@@ -102,11 +101,9 @@ public final class ModuleUtil {
installedModules = modules;
enabledModules = new HashSet<>(Arrays.asList(ConfigManager.getEnabledModules()));
- synchronized (this) {
- isReloading = false;
- }
+ modulesLoaded = true;
- for (var listener: listeners) {
+ for (var listener : listeners) {
listener.onModulesReloaded();
}
}
@@ -126,7 +123,7 @@ public final class ModuleUtil {
InstalledModule old = installedModules.remove(Pair.create(packageName, userId));
if (old != null) {
for (ModuleListener listener : listeners) {
- listener.onSingleInstalledModuleReloaded(old);
+ listener.onSingleModuleReloaded(old);
}
}
return null;
@@ -137,31 +134,33 @@ public final class ModuleUtil {
InstalledModule module = new InstalledModule(pkg);
installedModules.put(Pair.create(packageName, userId), module);
for (ModuleListener listener : listeners) {
- listener.onSingleInstalledModuleReloaded(module);
+ listener.onSingleModuleReloaded(module);
}
return module;
} else {
InstalledModule old = installedModules.remove(Pair.create(packageName, userId));
if (old != null) {
for (ModuleListener listener : listeners) {
- listener.onSingleInstalledModuleReloaded(old);
+ listener.onSingleModuleReloaded(old);
}
}
return null;
}
}
+ @Nullable
public InstalledModule getModule(String packageName, int userId) {
- return installedModules.get(Pair.create(packageName, userId));
+ return modulesLoaded ? installedModules.get(Pair.create(packageName, userId)) : null;
}
+ @Nullable
public InstalledModule getModule(String packageName) {
return getModule(packageName, 0);
}
@Nullable
synchronized public Map, InstalledModule> getModules() {
- return isReloading ? null : installedModules;
+ return modulesLoaded ? installedModules : null;
}
public boolean setModuleEnabled(String packageName, boolean enabled) {
@@ -181,12 +180,11 @@ public final class ModuleUtil {
}
public int getEnabledModulesCount() {
- return isReloading ? -1 : enabledModules.size();
+ return modulesLoaded ? enabledModules.size() : -1;
}
public void addListener(ModuleListener listener) {
- if (!listeners.contains(listener))
- listeners.add(listener);
+ listeners.add(listener);
}
public void removeListener(ModuleListener listener) {
@@ -198,7 +196,7 @@ public final class ModuleUtil {
* Called whenever one (previously or now) installed module has been
* reloaded
*/
- default void onSingleInstalledModuleReloaded(InstalledModule module) {
+ default void onSingleModuleReloaded(InstalledModule module) {
}
@@ -290,7 +288,7 @@ public final class ModuleUtil {
e.printStackTrace();
}
RepoLoader repoLoader = RepoLoader.getInstance();
- if (scopeList == null && repoLoader.isRepoLoaded()) {
+ if (scopeList == null) {
OnlineModule module = repoLoader.getOnlineModule(packageName);
if (module != null && module.getScope() != null) {
scopeList = module.getScope();
diff --git a/app/src/main/res/drawable/ic_baseline_chat_24.xml b/app/src/main/res/drawable/ic_baseline_chat_24.xml
new file mode 100644
index 00000000..26208ade
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_chat_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_info_24.xml b/app/src/main/res/drawable/ic_baseline_info_24.xml
new file mode 100644
index 00000000..17255b7a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_info_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_baseline_search_24.xml b/app/src/main/res/drawable/ic_baseline_search_24.xml
new file mode 100644
index 00000000..afe352ee
--- /dev/null
+++ b/app/src/main/res/drawable/ic_baseline_search_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/simple_menu_background.xml b/app/src/main/res/drawable/simple_menu_background.xml
index 05f09c23..45b614f7 100644
--- a/app/src/main/res/drawable/simple_menu_background.xml
+++ b/app/src/main/res/drawable/simple_menu_background.xml
@@ -19,13 +19,13 @@
-
-
+
-
-
+
diff --git a/app/src/main/res/layout/dialog_info.xml b/app/src/main/res/layout/dialog_info.xml
index 1c471598..82479d85 100644
--- a/app/src/main/res/layout/dialog_info.xml
+++ b/app/src/main/res/layout/dialog_info.xml
@@ -115,6 +115,7 @@
style="@style/DeviceInfoDialogValue"
android:id="@+id/system_abi"
android:layout_width="match_parent"
+ android:paddingBottom="0dp"
android:layout_height="wrap_content" />
diff --git a/app/src/main/res/layout/dialog_item.xml b/app/src/main/res/layout/dialog_item.xml
index 6e171613..55253813 100644
--- a/app/src/main/res/layout/dialog_item.xml
+++ b/app/src/main/res/layout/dialog_item.xml
@@ -31,6 +31,7 @@
style="@style/DeviceInfoDialogValue"
android:id="@+id/value"
android:gravity="center_vertical"
+ android:paddingBottom="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
diff --git a/app/src/main/res/menu/menu_home.xml b/app/src/main/res/layout/dialog_title.xml
similarity index 65%
rename from app/src/main/res/menu/menu_home.xml
rename to app/src/main/res/layout/dialog_title.xml
index c52f279e..ffdbb238 100644
--- a/app/src/main/res/menu/menu_home.xml
+++ b/app/src/main/res/layout/dialog_title.xml
@@ -14,20 +14,12 @@
~ You should have received a copy of the GNU General Public License
~ along with LSPosed. If not, see .
~
- ~ Copyright (C) 2020 EdXposed Contributors
~ Copyright (C) 2021 LSPosed Contributors
-->
-
-
+
diff --git a/app/src/main/res/layout/dialog_warning.xml b/app/src/main/res/layout/dialog_warning.xml
index 53cd5ff7..e5ce7ee0 100644
--- a/app/src/main/res/layout/dialog_warning.xml
+++ b/app/src/main/res/layout/dialog_warning.xml
@@ -18,8 +18,8 @@
-->
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content">
-
+
+
-
-
+
+
+
-
-
+ android:layout_height="wrap_content"
+ android:tooltipText="@string/Modules">
+ android:layout_height="wrap_content"
+ android:tooltipText="@string/module_repo">
+ android:layout_height="wrap_content"
+ android:tooltipText="@string/Logs">
+ android:layout_height="wrap_content"
+ android:tooltipText="@string/Settings">
+ android:layout_height="wrap_content"
+ android:tooltipText="@string/feedback_or_suggestion">
+ android:contentDescription="@string/feedback_or_suggestion"
+ app:srcCompat="@drawable/ic_baseline_chat_24" />
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_logs.xml b/app/src/main/res/layout/fragment_logs.xml
deleted file mode 100644
index a0efc4dd..00000000
--- a/app/src/main/res/layout/fragment_logs.xml
+++ /dev/null
@@ -1,104 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_pager.xml b/app/src/main/res/layout/fragment_pager.xml
index 79f58e3c..241afe50 100644
--- a/app/src/main/res/layout/fragment_pager.xml
+++ b/app/src/main/res/layout/fragment_pager.xml
@@ -19,7 +19,6 @@
-->
-
+
+
-
+
@@ -69,14 +73,6 @@
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
-
-
+ app:layout_fitsSystemWindowsInsets="bottom"
+ app:tint="?attr/colorSurface" />
diff --git a/app/src/main/res/layout/fragment_repo.xml b/app/src/main/res/layout/fragment_repo.xml
index 790ba270..ee688c65 100644
--- a/app/src/main/res/layout/fragment_repo.xml
+++ b/app/src/main/res/layout/fragment_repo.xml
@@ -20,7 +20,6 @@
-
+ app:layout_scrollFlags="scroll|exitUntilCollapsed"
+ app:titleCollapseMode="scale">
+
+
-
+
@@ -58,25 +63,23 @@
android:layout_height="wrap_content"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
-
+ android:layout_height="match_parent">
-
+
+
diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml
index ed7b694f..3ea3d2a6 100644
--- a/app/src/main/res/layout/fragment_settings.xml
+++ b/app/src/main/res/layout/fragment_settings.xml
@@ -20,7 +20,6 @@
-
+
+
-
+
diff --git a/app/src/main/res/layout/item_log_textview.xml b/app/src/main/res/layout/item_log_textview.xml
new file mode 100644
index 00000000..fabfacbb
--- /dev/null
+++ b/app/src/main/res/layout/item_log_textview.xml
@@ -0,0 +1,27 @@
+
+
diff --git a/app/src/main/res/layout/item_module.xml b/app/src/main/res/layout/item_module.xml
index 547ded4e..e2e1725d 100644
--- a/app/src/main/res/layout/item_module.xml
+++ b/app/src/main/res/layout/item_module.xml
@@ -39,8 +39,8 @@
diff --git a/app/src/main/res/layout/dialog_recyclerview.xml b/app/src/main/res/layout/swiperefresh_recyclerview.xml
similarity index 63%
rename from app/src/main/res/layout/dialog_recyclerview.xml
rename to app/src/main/res/layout/swiperefresh_recyclerview.xml
index e1186cb9..a66d1010 100644
--- a/app/src/main/res/layout/dialog_recyclerview.xml
+++ b/app/src/main/res/layout/swiperefresh_recyclerview.xml
@@ -17,29 +17,22 @@
~ Copyright (C) 2021 LSPosed Contributors
-->
-
-
-
-
+ android:scrollbars="vertical"
+ app:borderBottomVisibility="never"
+ app:borderTopDrawable="@null"
+ app:borderTopVisibility="whenTop"
+ app:fitsSystemWindowsInsets="bottom" />
+
diff --git a/app/src/main/res/menu/menu_app_list.xml b/app/src/main/res/menu/menu_app_list.xml
index 1555c0a4..f29c9d89 100644
--- a/app/src/main/res/menu/menu_app_list.xml
+++ b/app/src/main/res/menu/menu_app_list.xml
@@ -18,19 +18,15 @@
~ Copyright (C) 2021 LSPosed Contributors
-->
-