From 5d782a76803630addaeca19a56482ae334ece0d6 Mon Sep 17 00:00:00 2001 From: LoveSy Date: Sat, 27 Nov 2021 09:15:23 +0800 Subject: [PATCH] [app] More UI fix (#1438) --- app/build.gradle.kts | 2 +- .../SubtitleCollapsingToolbarLayout.java | 1246 +++++++++++++++++ .../SubtitleCollapsingTextHelper.java | 1235 ++++++++++++++++ .../main/java/org/lsposed/manager/App.java | 1 - .../org/lsposed/manager/ConfigManager.java | 1 - .../lsposed/manager/adapters/AppHelper.java | 5 +- .../manager/adapters/ScopeAdapter.java | 97 +- .../org/lsposed/manager/repo/RepoLoader.java | 162 +-- .../manager/ui/dialog/FlashDialogBuilder.java | 10 +- .../manager/ui/dialog/InfoDialogBuilder.java | 38 +- .../manager/ui/dialog/ShortcutDialog.java | 63 + .../ui/dialog/ShortcutDialogBuilder.java | 38 - .../ui/dialog/WarningDialogBuilder.java | 27 +- .../manager/ui/fragment/AppListFragment.java | 84 +- .../manager/ui/fragment/BaseFragment.java | 45 +- .../ui/fragment/CompileDialogFragment.java | 23 +- .../manager/ui/fragment/HomeFragment.java | 80 +- .../manager/ui/fragment/LogsFragment.java | 441 ++++-- .../manager/ui/fragment/ModulesFragment.java | 284 ++-- .../fragment/RecyclerViewDialogFragment.java | 85 ++ .../manager/ui/fragment/RepoFragment.java | 204 ++- .../manager/ui/fragment/RepoItemFragment.java | 258 ++-- .../manager/ui/fragment/SettingsFragment.java | 46 +- .../org/lsposed/manager/util/ModuleUtil.java | 50 +- .../main/res/drawable/ic_baseline_chat_24.xml | 10 + .../main/res/drawable/ic_baseline_info_24.xml | 10 + .../res/drawable/ic_baseline_search_24.xml | 10 + .../res/drawable/simple_menu_background.xml | 4 +- app/src/main/res/layout/dialog_info.xml | 1 + app/src/main/res/layout/dialog_item.xml | 1 + .../menu_home.xml => layout/dialog_title.xml} | 22 +- app/src/main/res/layout/dialog_warning.xml | 4 +- app/src/main/res/layout/fragment_app_list.xml | 45 +- app/src/main/res/layout/fragment_home.xml | 52 +- app/src/main/res/layout/fragment_logs.xml | 104 -- app/src/main/res/layout/fragment_pager.xml | 33 +- app/src/main/res/layout/fragment_repo.xml | 55 +- app/src/main/res/layout/fragment_settings.xml | 19 +- app/src/main/res/layout/item_log_textview.xml | 27 + app/src/main/res/layout/item_module.xml | 8 +- app/src/main/res/layout/item_onlinemodule.xml | 2 + .../res/layout/item_repo_recyclerview.xml | 6 +- ...view.xml => swiperefresh_recyclerview.xml} | 25 +- app/src/main/res/menu/menu_app_list.xml | 14 +- app/src/main/res/menu/menu_logs.xml | 11 +- app/src/main/res/menu/menu_modules.xml | 14 +- app/src/main/res/menu/menu_repo.xml | 20 +- app/src/main/res/values-af/strings.xml | 3 +- app/src/main/res/values-ar/strings.xml | 3 +- app/src/main/res/values/attrs.xml | 61 + app/src/main/res/values/strings.xml | 5 +- app/src/main/res/values/styles.xml | 10 + 52 files changed, 4065 insertions(+), 1039 deletions(-) create mode 100644 app/src/main/java/com/google/android/material/appbar/SubtitleCollapsingToolbarLayout.java create mode 100644 app/src/main/java/com/google/android/material/internal/SubtitleCollapsingTextHelper.java create mode 100644 app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialog.java delete mode 100644 app/src/main/java/org/lsposed/manager/ui/dialog/ShortcutDialogBuilder.java create mode 100644 app/src/main/java/org/lsposed/manager/ui/fragment/RecyclerViewDialogFragment.java create mode 100644 app/src/main/res/drawable/ic_baseline_chat_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_info_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_search_24.xml rename app/src/main/res/{menu/menu_home.xml => layout/dialog_title.xml} (65%) delete mode 100644 app/src/main/res/layout/fragment_logs.xml create mode 100644 app/src/main/res/layout/item_log_textview.xml rename app/src/main/res/layout/{dialog_recyclerview.xml => swiperefresh_recyclerview.xml} (63%) 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 --> - + - - + android:icon="@drawable/ic_baseline_search_24" + android:showAsAction="always|collapseActionView" + tools:ignore="AlwaysShowAction" /> - - + diff --git a/app/src/main/res/menu/menu_modules.xml b/app/src/main/res/menu/menu_modules.xml index 13eae77e..96ac92a5 100644 --- a/app/src/main/res/menu/menu_modules.xml +++ b/app/src/main/res/menu/menu_modules.xml @@ -18,18 +18,12 @@ ~ Copyright (C) 2021 LSPosed Contributors --> - + - - + android:actionViewClass="androidx.appcompat.widget.SearchView" + android:icon="@drawable/ic_baseline_search_24" + android:showAsAction="ifRoom|collapseActionView" /> diff --git a/app/src/main/res/menu/menu_repo.xml b/app/src/main/res/menu/menu_repo.xml index 7e49fa8c..14d7e28e 100644 --- a/app/src/main/res/menu/menu_repo.xml +++ b/app/src/main/res/menu/menu_repo.xml @@ -19,23 +19,19 @@ --> + xmlns:tools="http://schemas.android.com/tools"> - - + android:actionViewClass="androidx.appcompat.widget.SearchView" + android:icon="@drawable/ic_baseline_search_24" + android:showAsAction="always|collapseActionView" + tools:ignore="AlwaysShowAction" /> + android:showAsAction="never" + android:title="@string/menu_sort"> - \ No newline at end of file + diff --git a/app/src/main/res/values-af/strings.xml b/app/src/main/res/values-af/strings.xml index e1dfe14a..1158f96a 100644 --- a/app/src/main/res/values-af/strings.xml +++ b/app/src/main/res/values-af/strings.xml @@ -1,5 +1,4 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7912c0b9..bf44b3f0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,7 +14,6 @@ ~ 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 --> @@ -84,6 +83,8 @@ Reload Failed to clear the log Word Wrap + Verbose log enabled + Verbose log disabled Xposed module is not activated yet @@ -95,7 +96,6 @@ (no description provided) - This module does not provide a user interface This module requires a newer Xposed version (%d) and thus cannot be activated This module does not specify the Xposed version it needs. This module was created for Xposed version %1$d, but due to incompatible changes in version %2$d, it has been disabled @@ -234,4 +234,5 @@ Brown Grey Blue grey + Feedback or suggestion diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 1c0317f1..541743ad 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -21,4 +21,14 @@ +