Android关于自定义锁屏页和通知栏的一些适配问题

595 阅读6分钟

1.锁屏页

实现思路

自定义锁屏页大多使用悬浮窗和弹出Activity的方式实现,这里我参考了QQ音乐和网易云音乐的实现方式,在锁屏页面弹出Activity。首先,动态注册一个广播接收器用于监听手机熄屏和亮屏的广播,在接收到熄屏广播后启动Activity,在这里启动Activity时,还涉及到一个权限问题,由于一些手机厂商都有自己的自定义权限,这里我选择所有的权限让用户手动去开启(权限要动态校验)。到这一步已经差不多实现了,最后就是一些细节问题,滑动解锁以及沉浸式实现

监听亮屏和熄屏广播的代码网上都有,这里就不多说了,重点是权限适配的问题,我几乎找遍了全网,整理出下面这个工具类,直接上代码

权限适配
/**
 * 锁屏显示权限工具类
 *
 * @author Sago
 * @since 2024/12/7
 */
public class LockScreenPermissionUtil {
    private static final String TAG = "LockScreenPermissionUtil";

    private static final int HW_OP_CODE_POPUP_BACKGROUND_WINDOW = 100000;
    private static final int XM_OP_CODE_POPUP_BACKGROUND_WINDOW = 10021;

    private static Context sContext;

    private static boolean isInit = false;

    public static void init(Context context) {
        if (!isInit) {
            sContext = context.getApplicationContext();
            isInit = true;
        }
    }

    /**
     * 是否有后台弹出页面权限
     */
    public static boolean hasPopupBackgroundPermission() {
        if (isHuawei()) {
            return checkHwPermission();
        }
        if (isXiaoMi()) {
            return checkXmPermission();
        }
        if (isVivo()) {
            return checkVivoPermission();
        }
        if (isOppo() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            return Settings.canDrawOverlays(sContext);
        }
        return true;
    }

    /**
     * 是否有悬浮窗权限
     */
    public static boolean haveSystemAlertWindow() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            return Settings.canDrawOverlays(sContext);
        }
        return true;
    }

    public static boolean isHuawei() {
        return checkManufacturer("huawei");
    }

    public static boolean isXiaoMi() {
        return checkManufacturer("xiaomi");
    }

    public static boolean isOppo() {
        return checkManufacturer("oppo");
    }

    public static boolean isVivo() {
        return checkManufacturer("vivo");
    }

    private static boolean checkManufacturer(String manufacturer) {
        return manufacturer.equalsIgnoreCase(Build.MANUFACTURER);
    }

    /**
     * 华为后台弹窗权限
     *
     * @return
     */
    private static boolean checkHwPermission() {
        Context context = sContext;
        try {
            Class<?> c = Class.forName("com.huawei.android.app.AppOpsManagerEx");
            Method m = c.getDeclaredMethod(
                "checkHwOpNoThrow",
                AppOpsManager.class,
                int.class,
                int.class,
                String.class
            );
            Object result = m.invoke(
                c.newInstance(),
                context.getSystemService(Context.APP_OPS_SERVICE),
                HW_OP_CODE_POPUP_BACKGROUND_WINDOW,
                Binder.getCallingUid(),
                context.getPackageName()
            );
            Log.d(TAG, "LockScreenPermissionUtil checkHwPermission result: " + (AppOpsManager.MODE_ALLOWED == (int) result));
            return AppOpsManager.MODE_ALLOWED == (int) result;
        } catch (Exception e) {
            // ignore
        }
        return false;
    }

    private static boolean checkXmPermission() {
//        Context context = sContext;
//        AppOpsManager ops = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
//        try {
//            Method method = ops.getClass().getMethod(
//                    "checkOpNoThrow",
//                    int.class,
//                    int.class,
//                    String.class
//            );
//            Object result = method.invoke(
//                    ops,
//                    XM_OP_CODE_POPUP_BACKGROUND_WINDOW,
//                    Process.myUid(),
//                    context.getPackageName()
//            );
//            Log.d(TAG, "LockScreenPermissionUtil checkXmPermission result: " + (AppOpsManager.MODE_ALLOWED == (int) result));
//            return AppOpsManager.MODE_ALLOWED == (int) result;
//        } catch (Exception e) {
//            // ignore
//        }
//        return false;
        if (canStartFromBackground(sContext) && canShowOnLockScreen(sContext)) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * 小米后台启动页面权限
     *
     * @param context 上下文
     * @return MIUI是否可以从后台启动
     */
    public static boolean canStartFromBackground(Context context) {
        AppOpsManager ops = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
        try {
            int op = 10021;
            // >= 23
            // ops.checkOpNoThrow(op, uid, packageName)
            Method method = ops.getClass().getMethod("checkOpNoThrow", int.class, int.class, String.class);
            Integer result = (Integer) method.invoke(ops, op, android.os.Process.myUid(), context.getPackageName());
            return result == AppOpsManager.MODE_ALLOWED;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }

    /**
     * 小米锁屏显示权限
     *
     * @param context 上下文
     * @return MIUI是否可以锁屏显示
     */
    public static boolean canShowOnLockScreen(Context context) {
        AppOpsManager ops = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
        try {
            int op = 10020;
            // >= 23
            // ops.checkOpNoThrow(op, uid, packageName)
            Method method = ops.getClass().getMethod("checkOpNoThrow", int.class, int.class, String.class);
            Integer result = (Integer) method.invoke(ops, op, android.os.Process.myUid(), context.getPackageName());
            return result == AppOpsManager.MODE_ALLOWED;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }

    /**
     * vivo后台弹出页面权限
     *
     * @return
     */
    private static boolean checkVivoPermission() {
//        Context context = sContext;
//        Uri uri = Uri.parse("content://com.vivo.permissionmanager.provider.permission/start_bg_activity");
//        String selection = "pkgname = ?";
//        String[] selectionArgs = {context.getPackageName()};
//        int result = 1;
//        try {
//            context.getContentResolver().query(uri, null, selection, selectionArgs, null).close();
//        } catch (Exception exception) {
//            // ignore
//        }
//        Log.d(TAG, "LockScreenPermissionUtil checkVivoPermission result: " + (AppOpsManager.MODE_ALLOWED == result));
//        return result == AppOpsManager.MODE_ALLOWED;

        if (getvivoBgStartActivityPermissionStatus(sContext) == 0 && getVivoLockStatus(sContext) == 0) {
            return true;
        } else {
            return false;
        }
    }

    /**
     * 判断vivo后台弹出界面 1未开启 0开启
     *
     * @param context
     * @return
     */
    public static int getvivoBgStartActivityPermissionStatus(Context context) {
        String packageName = context.getPackageName();
        Uri uri2 = Uri.parse("content://com.vivo.permissionmanager.provider.permission/start_bg_activity");
        String selection = "pkgname = ?";
        String[] selectionArgs = new String[]{packageName};
        try {
            Cursor cursor = context
                    .getContentResolver()
                    .query(uri2, null, selection, selectionArgs, null);
            if (cursor != null) {
                if (cursor.moveToFirst()) {
                    int currentmode = cursor.getInt(cursor.getColumnIndex("currentstate"));
                    cursor.close();
                    return currentmode;
                } else {
                    cursor.close();
                    return 1;
                }
            }
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        return 1;
    }

    /**
     * 判断vivo锁屏显示 1未开启 0开启
     * @param context
     * @return
     */
    public static int getVivoLockStatus(Context context) {
        String packageName = context.getPackageName();
        Uri uri2 = Uri.parse("content://com.vivo.permissionmanager.provider.permission/control_locked_screen_action");
        String selection = "pkgname = ?";
        String[] selectionArgs = new String[]{packageName};
        try {
            Cursor cursor = context
                    .getContentResolver()
                    .query(uri2, null, selection, selectionArgs, null);
            if (cursor != null) {
                if (cursor.moveToFirst()) {
                    int currentmode = cursor.getInt(cursor.getColumnIndex("currentstate"));
                    cursor.close();
                    return currentmode;
                } else {
                    cursor.close();
                    return 1;
                }
            }
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        return 1;
    }

    /**
     * 启动应用详情页面
     */
    private void startCustomSystemAlertWindow() {
        Intent intent = new Intent();
        intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
        intent.setData(Uri.parse("package:" + sContext.getPackageName()));
        sContext.startActivity(intent);
    }
}
滑动解锁

使用方法:new这个CircleProgressDrawable对象,与ImageView绑定即可

/**
 * 进度条
 *
 * @author Sago
 * @since 2024/9/4
 */
public class CircleProgressDrawable extends Drawable {

    private static final float DEFAULT_SHADOW_DRAW_PERCENT = 0.08F;
    private static final String DEFAULT_SHADOW_START_COLOR = "#70000000";

    private int bgColor = Color.parseColor("#1F000000");
    private int pColor = Color.RED;
    private float bgWidth = 8F;
    private float pWidth = 8F;
    private float shadowPercent = DEFAULT_SHADOW_DRAW_PERCENT;

    private int width = 0;
    private int height = 0;
    private int centerX = 0;
    private int centerY = 0;
    private float radius = 0F;
    private float drawPercent = 0F;

    private Paint mBgPaint;
    private Paint mPaint;
    private Paint mShadowPaint;

    private Shader mShadowShader;

    private int progress = 0;
    private int total = 0;

    //声明构造函数
    public CircleProgressDrawable(int bgColor, int pColor, float bgWidth, float pWidth) {
        this.bgColor = bgColor;
        this.pColor = pColor;
        this.bgWidth = bgWidth;
        this.pWidth = pWidth;

        initPaints();
    }

    private void initPaints() {
        mBgPaint = new Paint();
        mBgPaint.setColor(bgColor);
        mBgPaint.setStyle(Paint.Style.STROKE);
        mBgPaint.setStrokeWidth(bgWidth);

        mPaint = new Paint();
        mPaint.setColor(pColor);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(pWidth);
        mPaint.setStrokeCap(Paint.Cap.ROUND);
        mPaint.setAntiAlias(true);

        mShadowPaint = new Paint();
        mShadowPaint.setStrokeWidth(pWidth);
        mShadowPaint.setStyle(Paint.Style.STROKE);
        mShadowPaint.setStrokeCap(Paint.Cap.SQUARE);
        mShadowPaint.setAntiAlias(true);

        mShadowShader = new SweepGradient(
            0F, 0F,
            new int[]{Color.parseColor(DEFAULT_SHADOW_START_COLOR), Color.TRANSPARENT},
            new float[]{0F, shadowPercent}
        );
    }

    public void setProgress(int progress) {
        this.progress = progress;
        invalidateSelf();
    }

    public void setTotal(int total) {
        this.total = total;
        invalidateSelf();
    }

    public void setShadowPercent(@FloatRange(from = 0.0, to = 1.0) float shadowPercent) {
        this.shadowPercent = shadowPercent;
        mShadowShader = new SweepGradient(
            0F, 0F,
            new int[]{Color.parseColor(DEFAULT_SHADOW_START_COLOR), Color.TRANSPARENT},
            new float[]{0F, shadowPercent}
        );
        invalidateSelf();
    }

    @Override
    public void draw(Canvas canvas) {
        width = getBounds().width();
        height = getBounds().height();
        centerX = (getBounds().left + getBounds().right) / 2;
        centerY = (getBounds().top + getBounds().bottom) / 2;
        radius = width / 2F - Math.max(bgWidth, pWidth) / 2F;

        canvas.save();
        canvas.translate(centerX, centerY);
        canvas.rotate(90F);

        mBgPaint.setShader(null);  // Ensure no shader is set for background
        canvas.drawCircle(0F, 0F, radius, mBgPaint);

        drawPercent = (progress % Math.max(total, 1)) / (float) Math.max(total, 1);

        if (progress == 0) {
            canvas.drawPoint(radius, 0F, mPaint);
        } else if (progress < total) {
            canvas.drawArc(
                -radius, -radius, radius, radius,
                0F, 360F * drawPercent,
                false, mPaint
            );
        } else if (progress == total) {
            canvas.drawCircle(0F, 0F, radius, mPaint);
        } else {
            canvas.drawCircle(0F, 0F, radius, mPaint);

            canvas.save();
            canvas.rotate(360F * drawPercent);
            mShadowPaint.setShader(mShadowShader);
            canvas.drawArc(
                -radius, -radius, radius, radius,
                0F, 360F * shadowPercent,
                false, mShadowPaint
            );
            canvas.restore();

            canvas.drawArc(
                -radius, -radius, radius, radius,
                0F, 360F * drawPercent,
                false, mPaint
            );
        }

        canvas.restore();
    }

    @Override
    public void setAlpha(int alpha) {
        mPaint.setAlpha(alpha);
    }

    @Override
    public void setColorFilter(ColorFilter colorFilter) {
        mPaint.setColorFilter(colorFilter);
    }

    @Override
    public int getOpacity() {
        return PixelFormat.TRANSLUCENT;
    }
}

滑动解锁xml代码

<TextView
    android:id="@+id/text_slider"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_alignParentBottom="true"
    android:layout_centerHorizontal="true"
    android:layout_marginBottom="50dp"
    android:text="@string/text_slider"
    android:textColor="#ffffff"
    android:textSize="14sp" />
<ImageView
    android:id="@+id/slider_icon"
    android:layout_width="7.01dp"
    android:layout_height="12.02dp"
    android:layout_alignParentBottom="true"
    android:layout_marginBottom="53.5dp"
    android:layout_toEndOf="@id/text_slider"
    android:layout_marginStart="8.98dp"
    android:src="@drawable/slider_icon" />
沉浸式

在Activity中实现这个方法即可

@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    //沉浸式状态栏
    if (hasFocus) {
        View decorView = getWindow().getDecorView();
        decorView.setSystemUiVisibility(
                View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                        | View.SYSTEM_UI_FLAG_FULLSCREEN
                        | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
    }
}

2.通知栏

版本兼容

安卓8.0以上需要创建通道

NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
//安卓8.0以上需要创建通道
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
    NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "运动数据", NotificationManager.IMPORTANCE_DEFAULT);
    channel.setSound(null, null);
    notificationManager.createNotificationChannel(channel);
}

发送自定义通知(notificationLayout为RemoteViews对象,每次更新需要重新new一个,否则可能会出现一些显示异常的问题)

//构建通知
Notification notification = new NotificationCompat.Builder(context, CHANNEL_ID)
        .setPriority(NotificationCompat.PRIORITY_DEFAULT)
        .setCustomBigContentView(notificationLayout)
        .setCustomContentView(notificationLayout)
        .setSmallIcon(R.drawable.xxx)
        .setContentTitle("xxx")
        .setOngoing(true)
        .build();
//更新通知
notificationManager.notify(1, notification);
暗色主题适配

这里我是注册了一个Service监听暗色模式的切换,并且定义了两套主题以适应暗色和亮色模式

@Override
public void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    int currentNightMode = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
    if (currentNightMode == Configuration.UI_MODE_NIGHT_YES) {
        Log.d("ThemeMode", "Dark mode is enabled");
    } else if (currentNightMode == Configuration.UI_MODE_NIGHT_NO) {
        Log.d("ThemeMode", "Light mode is enabled");
    }
}

注意,在首次发送通知前需要获取一次当前系统模式,避免颜色设置主题出错

/**
 * 检查当前系统是否为暗色模式
 * @param context 上下文
 * @return 是否为暗色模式
 */
public static boolean isDarkMode(Context context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        // Android 10 及以上版本,使用 UiModeManager
        return isDarkModeUsingUiMode(context);
    } else {
        // Android 9 及以下版本,使用 Configuration
        return isDarkModeUsingConfiguration(context);
    }
}

/**
 * 使用 UiModeManager 获取暗色模式状态(适用于 Android 10 及以上)
 * @param context 上下文
 * @return 是否为暗色模式
 */
private static boolean isDarkModeUsingUiMode(Context context) {
    UiModeManager uiModeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
    int nightMode = uiModeManager.getNightMode();
    return nightMode == UiModeManager.MODE_NIGHT_YES; // 暗色模式
}

/**
 * 使用 Configuration 获取暗色模式状态(适用于 Android 9 及以下)
 * @param context 上下文
 * @return 是否为暗色模式
 */
private static boolean isDarkModeUsingConfiguration(Context context) {
    int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
    return nightModeFlags == Configuration.UI_MODE_NIGHT_YES; // 暗色模式
}
通知清除

在Service中实现下面方法,即可实现应用退出后台自动改清除通知

@Override
public void onTaskRemoved(Intent rootIntent) {
    NotificationManager notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
    notificationManager.cancelAll();
}