【Android】當關閉通知消息權限後無法顯示系統Toast的解決方案

前言

不知道大家是否遇到了當你們的App在5.0以上系統中被用戶關閉消息通知後(其實用戶本身只是想關閉Notification的,猜測),系統的Toast也神奇的無法顯示。當然這個問題並不複雜,有很多種解決方案,我們逐一探討一下,然後來看看到底哪種方式會好一點。


插樓~【Android】當關閉通知權限後無法顯示Toast的解決方案V2.0 已經更新嘍~推薦使用2.0.x以後的版本哦~這邊以後將不在維護~不過沒看過這博文的可以學習一下~多多益善嘛~


乾貨

項目地址:github地址
1.在project的build文件中添加如下:

allprojects {
    repositories {
        jcenter()
        maven { url 'https://jitpack.io' }
    }
}

2.然後在app的build文件依賴即可

 compile 'com.github.Blincheng:Toast:v1.4.1'

3.使用和系統的Toast完全一致,唯一要注意的就是該Toast的路徑是import com.mic.toast.Toast;然後由於是基於Activity創建的佈局,所以傳入的Context應用一定要是Activity哦~建議在BaseActivity中如下使用

public void showShortText(CharSequence text) {
    Toast.makeText(BaseActivity.this, text, Toast.LENGTH_SHORT).show();
}

問題分析

直接跟蹤Toast的源碼,其實我們可以發現,果真Toast其實是通過NotificationManagerService 維護一個toast隊列,然後通知給Toast中的客戶端 TN 調用 WindowManager 添加view。那麼當用戶關閉通知權限後自然也無法顯示Toast了。

/**
     * Show the view for the specified duration.
     */
    public void show() {
        if (mNextView == null) {
            throw new RuntimeException("setView must have been called");
        }

        INotificationManager service = getService();
        String pkg = mContext.getOpPackageName();
        TN tn = mTN;
        tn.mNextView = mNextView;

        try {
            service.enqueueToast(pkg, tn, mDuration);
        } catch (RemoteException e) {
            // Empty
        }
    }
    ....
    static private INotificationManager getService() {
        if (sService != null) {
            return sService;
        }
        sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
        return sService;
    }

解決思路

這邊就來說說我這邊的幾種解決方案,就是大致我能想到的,哈哈。

  • 自己仿照系統的Toast然後用自己的消息隊列來維護,讓其不受NotificationManagerService影響。
  • 通過WindowManager自己來寫一個通知。
  • 通過Dialog、PopupWindow來編寫一個自定義通知。
  • 通過直接去當前頁面最外層content佈局來添加View。

仿照系統Toast自己來維護Toast消息隊列

這部分我就不寫了,大家有興趣可以看下解決小米MIUI系統上後臺應用沒法彈Toast的問題 這篇博文,東西寫的很詳細,內容也很細,大家可以看看。

通過WindowManager自己來寫一個通知

說起WindowManager,其實我對這個東西的第一印象就是強大,懸浮窗什麼的其實都是通過WindowManager來實現的,那麼我們來看看怎麼實現,我就直接上代碼了

package com.mic.utils;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.PixelFormat;
import android.os.Handler;
import android.view.Gravity;
import android.view.View;
import android.view.WindowManager;
import android.widget.TextView;

/**
 * Created by bl on 2016/10/11.
 */

public class Toast {
    private Context mContext;
    private WindowManager wm;
    private int mDuration;
    private View mNextView;
    public static final int LENGTH_SHORT = 1500;
    public static final int LENGTH_LONG = 3000;

    public Toast(Context context) {
        mContext = context.getApplicationContext();
        wm = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
    }

    public static Toast makeText(Context context, CharSequence text,
                                 int duration) {
        Toast result = new Toast(context);
        View view = android.widget.Toast.makeText(context, text, android.widget.Toast.LENGTH_SHORT).getView();
        if (view != null){
            TextView tv = (TextView) view.findViewById(android.R.id.message);
            tv.setText(text);
        }
        result.mNextView = view;
        result.mDuration = duration;
        return result;
    }

    public static Toast makeText(Context context, int resId, int duration)
            throws Resources.NotFoundException {
        return makeText(context, context.getResources().getText(resId),duration);
    }

    public void show() {
        if (mNextView != null) {
            WindowManager.LayoutParams params = new WindowManager.LayoutParams();
            params.gravity = Gravity.CENTER | Gravity.CENTER_HORIZONTAL;
            params.height = WindowManager.LayoutParams.WRAP_CONTENT;
            params.width = WindowManager.LayoutParams.WRAP_CONTENT;
            params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                    | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
                    | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
            params.format = PixelFormat.TRANSLUCENT;
            params.windowAnimations = android.R.style.Animation_Toast;
            params.y = dip2px(mContext, 64);
            params.type = WindowManager.LayoutParams.TYPE_TOAST;
            wm.addView(mNextView, params);
            new Handler().postDelayed(new Runnable() {

                @Override
                public void run() {
                    if (mNextView != null) {
                        wm.removeView(mNextView);
                        mNextView = null;
                        wm = null;
                    }
                }
            }, mDuration);
        }
    }

    /**
     * dip與px的轉換
     *
     * @參數   @param context
     * @參數   @param dipValue
     * @返回值 int
     *
     */
    private int dip2px(Context context, float dipValue) {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dipValue * scale + 0.5f);
    }
}

嗯,這樣寫應該是沒問題的,然後爲啥沒有效果呢??好吧,其實寫了這麼多,就是給自己挖坑,很明顯,這個東西在現在的5.0以上機器中有一個懸浮窗權限,而且系統默認是關閉該權限的,只有用戶手動打開才能顯示,而且代碼中也要添加如下一條權限。

<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

那麼問題又回來了,用戶一般不會打開,這不是又白搞麼。

通過Dialog、PopupWindow來編寫一個自定義通知。

這個方案貌似也是可行的,代碼就不寫了,提醒一點就是一般來說Dialog和PopupWindow顯示時有一個隔板,用戶是無法點擊其餘部分控件的,所以記得加上以上屬性。

 public static void setPopupWindowTouchModal(PopupWindow popupWindow,
                                                boolean touchModal) {
        if (null == popupWindow) {
            return;
        }
        Method method;
        try {
            method = PopupWindow.class.getDeclaredMethod("setTouchModal",
                    boolean.class);
            method.setAccessible(true);
            method.invoke(popupWindow, touchModal);

        }
        catch (Exception e) {
            e.printStackTrace();
        }
    }

通過直接去當前頁面最外層content佈局來添加View。

說說這種方式吧,其實剛開始我也是沒有想到的,因爲一般很少回去直接拿Activity最外層的content佈局去創建一個View並且顯示在上面的。

(ViewGroup) ((Activity) context).findViewById(android.R.id.content);

其實我們是可以直接通過findViewById去直接拿到最外層佈局的哦,當然context記得一定是Activity。
然後通過以下代碼就可以直接把佈局顯示在當前content佈局之上。

ViewGroup container = (ViewGroup) ((Activity) context).findViewById(android.R.id.content);
View v = ((Activity) context).getLayoutInflater().inflate(R.layout.etoast,container);

這種方式是不是有點奇怪,好吧,我也是這麼想的,不過感覺還是非常的實在的,也不復雜,東西也不多,直接上代碼。

public class EToast {
    public static final int LENGTH_SHORT = 0;
    public static final int LENGTH_LONG = 1;
    private final int ANIMATION_DURATION = 600;
    private WeakReference<Activity> reference;
    private TextView mTextView;
    private ViewGroup container;
    private View v;
    private LinearLayout mContainer;
    private int HIDE_DELAY = 2000;
    private AlphaAnimation mFadeOutAnimation;
    private AlphaAnimation mFadeInAnimation;
    private boolean isShow = false;
    private String TOAST_TAG = "EToast_Log";

    private EToast(Activity activity) {
        reference = new WeakReference<>(activity);
        container = (ViewGroup) activity
                .findViewById(android.R.id.content);
        View viewWithTag = container.findViewWithTag(TOAST_TAG);
        if(viewWithTag == null){
            v = activity.getLayoutInflater().inflate(
                    R.layout.etoast, container);
            v.setTag(TOAST_TAG);
        }else{
            v = viewWithTag;
        }
        mContainer = (LinearLayout) v.findViewById(R.id.mbContainer);
        mContainer.setVisibility(View.GONE);
        mTextView = (TextView) v.findViewById(R.id.mbMessage);
    }

    /**
     * @param context must instanceof Activity
     * */
    public static EToast makeText(Context context, CharSequence message, int HIDE_DELAY) {
        if(context instanceof Activity){
            EToast eToast = new EToast((Activity) context);
            if(HIDE_DELAY == LENGTH_LONG){
                eToast.HIDE_DELAY = 2500;
            }else{
                eToast.HIDE_DELAY = 1500;
            }
            eToast.setText(message);
            return eToast;
        }else{
            throw new RuntimeException("EToast @param context must instanceof Activity");
        }
    }
    public static EToast makeText(Context context, int resId, int HIDE_DELAY) {
        return makeText(context,context.getText(resId),HIDE_DELAY);
    }
    public void show() {
        if(isShow){
            return;
        }
        isShow = true;
        mFadeInAnimation = new AlphaAnimation(0.0f, 1.0f);
        mFadeOutAnimation = new AlphaAnimation(1.0f, 0.0f);
        mFadeOutAnimation.setDuration(ANIMATION_DURATION);
        mFadeOutAnimation
                .setAnimationListener(new Animation.AnimationListener() {
                    @Override
                    public void onAnimationStart(Animation animation) {
                        isShow = false;
                    }

                    @Override
                    public void onAnimationEnd(Animation animation) {
                        if(!reference.get().isFinishing())
                            mContainer.setVisibility(View.GONE);
                    }

                    @Override
                    public void onAnimationRepeat(Animation animation) {
                    }
                });
        mContainer.setVisibility(View.VISIBLE);

        mFadeInAnimation.setDuration(ANIMATION_DURATION);

        mContainer.startAnimation(mFadeInAnimation);
        mContainer.postDelayed(mHideRunnable,HIDE_DELAY);
    }

    private final Runnable mHideRunnable = new Runnable() {
        @Override
        public void run() {
            if (reference.get().hasWindowFocus())
                mContainer.startAnimation(mFadeOutAnimation);
            else{
                if(!reference.get().isFinishing())
                    mContainer.setVisibility(View.GONE);
            }
        }
    };
    public void cancel(){
        if(isShow) {
            isShow = false;
            mContainer.setVisibility(View.GONE);
            mContainer.removeCallbacks(mHideRunnable);
        }
    }
    public void setText(CharSequence s){
        if(v == null) throw new RuntimeException("This Toast was not created with com.mic.toast.Toast.makeText()");
        mTextView.setText(s);
    }
    public void setText(int resId) {
        setText(reference.get().getText(resId));
    }
}

簡單說下吧,代碼應該是很簡單的,然後簡單封裝了和Toast相同的幾個方法。嗯,其實大家也應該能發現我這邊的佈局其實是一直都在的,只是直接GONE掉了。所以呢,還是有待優化的地方,當然可以去想想是不是可以直接remove()掉什麼的。我這邊也沒有用隊列,我覺得在一個Toast顯示的期間如果再需要顯示另一個Toast,直接把當前的文本改過來就好了,沒有必要搞個隊列的,而且系統Toast我最厭惡的就是這個了,用戶如果不停的點擊,那Toast一個接一個的顯示,這個我覺得是不合理的。上面的佈局文件我也貼一下吧。有一點大家還是要注意下,因爲我在完善的過程中其實遇到了很多種情況的BUG,所以最終需要大家再BaseActivity中的onDestory()方法中去手動調用一下EToast.reset();具體可以看源碼中的解釋。
etoast.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:id="@+id/mbContainer"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="50dp"
    android:paddingRight="50dp"
    android:layout_marginBottom="50dp"
    android:gravity="bottom|center">
    <LinearLayout
        android:id="@+id/toast_linear"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/shape_eroast_bg"
        android:gravity="bottom|center"
        android:padding="5dp"
        android:orientation="vertical" >

        <TextView
            android:id="@+id/mbMessage"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="center"
            android:layout_margin="5dp"
            android:layout_gravity="center"
            android:textColor="#ffffffff"
            android:shadowColor="#BB000000"
            android:shadowRadius="2.75"/>
    </LinearLayout>
</LinearLayout>

shape_eroast_bg.xml

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 實心 -->
    <solid
        android:color="@color/BlackTransparent" />
    <corners
        android:radius="45dp"
        />
</shape>

優化

上面的幾種方式我大致也都走了一遍,其實我覺得都沒啥區別,看你喜歡用哪種吧。我其實是採用了第四種,因爲第一種的話我是不喜歡隊列的,比如5個Toast排隊還要一個一個等待顯示,這樣的體驗我是不喜歡的。第二種就不推薦了,因爲又涉及到了其他的權限。第三種我沒試,實現應該是不難的,效果的話也是隨你喜歡。最後我採用的是第四種,因爲這種方式之前是沒有用到過的,也嘗試一下。

那麼來說說優化,如果直接替換掉系統的Toast,那相當的暴力,肯定妥妥的。那麼我們能不能智能的去判斷一下呢,如果用戶沒有關閉通知權限,那麼久跟隨系統的Toast去吧,這樣好讓App採用系統風格,對吧。
方法是有的,如下:

/**
 * 用來判斷是否開啓通知權限
 * */
    private static boolean isNotificationEnabled(Context context){

        AppOpsManager mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
        ApplicationInfo appInfo = context.getApplicationInfo();

        String pkg = context.getApplicationContext().getPackageName();

        int uid = appInfo.uid;

        Class appOpsClass = null; /* Context.APP_OPS_MANAGER */

        try {

            appOpsClass = Class.forName(AppOpsManager.class.getName());

            Method checkOpNoThrowMethod = appOpsClass.getMethod(CHECK_OP_NO_THROW, Integer.TYPE, Integer.TYPE, String.class);

            Field opPostNotificationValue = appOpsClass.getDeclaredField(OP_POST_NOTIFICATION);
            int value = (int)opPostNotificationValue.get(Integer.class);
            return ((int)checkOpNoThrowMethod.invoke(mAppOps,value, uid, pkg) == AppOpsManager.MODE_ALLOWED);

        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }

據說android24 可以使用NotificationManagerCompat.areNotificationsEnabled()來判斷,具體大家可以嘗試。那麼如何來替換老項目中的Toast呢?
我這邊的話自定義Toast就是EToast了。爲什麼要E開頭呢,因爲公……你懂的。然後寫一個Toast的工具類,如下:

/**
 * Created by blin on 2016/10/11.
 */

public class Toast {
    private static final String CHECK_OP_NO_THROW = "checkOpNoThrow";
    private static final String OP_POST_NOTIFICATION = "OP_POST_NOTIFICATION";
    private static int checkNotification = 0;
    private Object mToast;
    public static final int LENGTH_SHORT = 0;
    public static final int LENGTH_LONG = 1;
    private Toast(Context context, String message, int duration) {
        try{
            checkNotification = isNotificationEnabled(context) ? 0 : 1;
            if (checkNotification == 1 && context instanceof Activity) {
                mToast = EToast.makeText(context, message, duration);
            } else {
                mToast = android.widget.Toast.makeText(context, message, duration);
            }
        }catch (Exception e){
            e.printStackTrace();
        }
    }
    private Toast(Context context, int resId, int duration) {
        if (checkNotification == -1){
            checkNotification = isNotificationEnabled(context) ? 0 : 1;
        }

        if (checkNotification == 1 && context instanceof Activity) {
            mToast = EToast.makeText(context, resId, duration);
        } else {
            mToast = android.widget.Toast.makeText(context, resId, duration);
        }
    }

    public static Toast makeText(Context context, String message, int duration) {
        return new Toast(context,message,duration);
    }
    public static Toast makeText(Context context, int resId, int duration) {
        return new Toast(context,resId,duration);
    }

    public void show() {
        if(mToast instanceof EToast){
            ((EToast) mToast).show();
        }else if(mToast instanceof android.widget.Toast){
            ((android.widget.Toast) mToast).show();
        }
    }
    public void cancel(){
        if(mToast instanceof EToast){
            ((EToast) mToast).cancel();
        }else if(mToast instanceof android.widget.Toast){
            ((android.widget.Toast) mToast).cancel();
        }
    }
    public void setText(int resId){
        if(mToast instanceof EToast){
            ((EToast) mToast).setText(resId);
        }else if(mToast instanceof android.widget.Toast){
            ((android.widget.Toast) mToast).setText(resId);
        }
    }
    public void setText(CharSequence s){
        if(mToast instanceof EToast){
            ((EToast) mToast).setText(s);
        }else if(mToast instanceof android.widget.Toast){
            ((android.widget.Toast) mToast).setText(s);
        }
    }
    /**
     * 用來判斷是否開啓通知權限
     * */
    private static boolean isNotificationEnabled(Context context){
        if(android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.KITKAT){
            return true;
        }
        AppOpsManager mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
        ApplicationInfo appInfo = context.getApplicationInfo();

        String pkg = context.getApplicationContext().getPackageName();

        int uid = appInfo.uid;

        Class appOpsClass = null; /* Context.APP_OPS_MANAGER */

        try {

            appOpsClass = Class.forName(AppOpsManager.class.getName());

            Method checkOpNoThrowMethod = appOpsClass.getMethod(CHECK_OP_NO_THROW, Integer.TYPE, Integer.TYPE, String.class);

            Field opPostNotificationValue = appOpsClass.getDeclaredField(OP_POST_NOTIFICATION);
            int value = (int)opPostNotificationValue.get(Integer.class);
            return ((int)checkOpNoThrowMethod.invoke(mAppOps,value, uid, pkg) == AppOpsManager.MODE_ALLOWED);

        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }
}

然後直接把你項目的import android.widget.Toast 全局替換成import 你Toast的包名 即可。

總結

最後呢,提前祝大家週末愉快,這周瘋狂的七天工作日過了明天就要結束了,開黑開黑!!哈哈哈~

ps:如果發現我上面有什麼問題或者有好的解決方案的話歡迎和我留言討論,比比在此謝過啦~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章