Android 系统级 USB 存储检测的工程化实现(抗 ROM、抗广播丢失)

在 Android 设备上做 U 盘检测 ,如果只是 Demo,很简单;

但在 系统级 App / 定制 ROM / 量产设备 上跑,事情会迅速变得复杂。

这篇文章分享一个真实工程中可长期运行的 USB 存储检测方案,目标只有一句话:

稳定判断:是否存在可用的 USB 存储,并给出明确状态


一、为什么"监听广播"远远不够

很多示例代码会这么写:

  • 监听 USB_DEVICE_ATTACHED

  • 监听 ACTION_MEDIA_MOUNTED

  • 收到就认为"U 盘插入了"

但在真实设备上,会遇到这些问题:

  • App 冷启动时,广播已经发完

  • USB 键盘 / 鼠标 / HID 也会发 USB_DEVICE_ATTACHED

  • 部分 ROM 乱序 / 重复 / 丢失广播

  • U 盘插入了,但 文件系统挂载失败

  • 拔盘后还残留旧路径,业务误判

结论很残酷:

广播只能当线索,不能当事实


二、设计目标与状态模型

1️⃣ 设计目标

  • 冷启动可检测

  • 热插拔可检测

  • 不误判 HID

  • 挂载异常不死循环

  • 状态语义清晰

2️⃣ 状态定义

复制代码
public enum UsbState {
    INIT,               // 初始化阶段
    NO_USB,             // 没有 USB 存储
    USB_ATTACHED,       // USB 设备存在,但无可用存储
    STORAGE_MOUNTED     // USB 存储已挂载,可读写
}

这个状态划分非常关键,尤其是 USB_ATTACHED,它明确表示:

"设备存在 ≠ 存储可用"


三、整体思路(工程级)

核心思路只有三点:

  1. 启动阶段主动探测(防广播丢失)

  2. 广播只作为触发器

  3. 最终判断以 StorageManager 为准


四、核心实现代码

1️⃣ 启动时主动检测(兜底)

复制代码
private void dispatchAppStart() {
    handler.postDelayed(this::detectOnStartup, 1000);
}

private void detectOnStartup() {
    File root = UsbPathResolver.getUsbRoot(context);
    if (root != null) {
        usbRoot = root;
        setState(UsbState.STORAGE_MOUNTED);
    } else if (startupRetry < MAX_RETRY) {
        startupRetry++;
        handler.postDelayed(this::detectOnStartup, 1000);
    } else {
        setState(UsbState.NO_USB);
    }
}

原因:

  • App 启动时,系统往往已经完成挂载

  • 广播无法补发,只能主动探测


2️⃣ USB 插入(不等于存储可用)

复制代码
private void onUsbAttached() {
    if (hasUsb(context)) {
        setState(UsbState.STORAGE_MOUNTED);
    } else if (currentState == UsbState.NO_USB || currentState == UsbState.INIT) {
        setState(UsbState.USB_ATTACHED);
    }
}
  • 过滤 HID 设备

  • 区分"设备存在"和"存储可用"


3️⃣ 存储挂载(带重试上限,防死循环)

复制代码
private void onStorageMounted() {
    File root = UsbPathResolver.getUsbRoot(context);
    if (root != null) {
        mountRetry = 0;
        usbRoot = root;
        setState(UsbState.STORAGE_MOUNTED);
    } else if (mountRetry++ < MAX_MOUNT_RETRY) {
        handler.postDelayed(this::onStorageMounted, 500);
    } else {
        setState(UsbState.USB_ATTACHED);
    }
}

这是整套代码最关键的一段:

  • 防止 ROM 发了 MEDIA_MOUNTED 但实际不可用

  • 防止主线程无限轮询

  • 明确区分"挂载失败"和"没有 USB"


4️⃣ 拔出处理(必须清理路径)

复制代码
private void onUsbDetached() {
    usbRoot = null;
    setState(UsbState.NO_USB);
}

private void onStorageRemoved() {
    usbRoot = null;
    setState(UsbState.NO_USB);
}

原则:

状态为 NO_USB,就不应该还能拿到旧路径


5️⃣ 是否存在 USB 存储(最终判定)

复制代码
public static boolean hasUsb(Context context) {
    StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
    if (sm == null) return false;
    List<StorageVolume> volumes = sm.getStorageVolumes();
    for (StorageVolume v : volumes) {
        if (v.isRemovable() && v.getState().equals(Environment.MEDIA_MOUNTED)) {
            return true;
        }
    }
    return false;
}

这是唯一可信的事实来源。


6️⃣ 查找业务目录(例如 update)

复制代码
@Nullable
public static File findUpdateDir(File usbRoot) {
    if (usbRoot == null || !usbRoot.exists() || !usbRoot.isDirectory()) {
        return null;
    }
    File update = new File(usbRoot, "update");
    if (update.exists() && update.isDirectory()) {
        return update;
    }
    return null;
}

五、使用方式示例

复制代码
UsbManagerHelper usbHelper = new UsbManagerHelper(this);
usbHelper.setListener(state -> {
    if (state == UsbManagerHelper.UsbState.STORAGE_MOUNTED) {
        File root = usbHelper.getUsbRoot();
        File updateDir = UsbManagerHelper.findUpdateDir(root);
        if (updateDir != null) {
            // 处理逻辑
        }
    }
});
usbHelper.start();

六、总结(踩坑换来的经验)

这套方案解决了以下真实问题:

  • ✅ 冷启动无广播

  • ✅ HID 设备误判

  • ✅ ROM 广播乱序

  • ✅ 挂载异常死循环

  • ✅ 拔盘路径残留

核心经验只有一句:

广播不可靠,状态机兜底,StorageManager 才是事实


七、适用场景

  • 系统级 App

  • 工控 / 医疗 / 车机 / 设备终端

  • U 盘升级、日志导出、数据导入

  • 定制 Android ROM

整体代码:

复制代码
package com.test.test.usb;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;
import android.util.Log;

import java.io.File;
import java.util.List;

import androidx.annotation.Nullable;

public class UsbManagerHelper {

    private static final String TAG = "=================UsbManagerHelper";

    private final Context context;
    private UsbState currentState = UsbState.INIT;
    private UsbStateListener listener;

    private final Handler handler = new Handler(Looper.getMainLooper());
    private int startupRetry = 0;
    private static final int MAX_RETRY = 5;
    private int mountRetry = 0;
    private static final int MAX_MOUNT_RETRY = 10;
    private File usbRoot;

    public UsbManagerHelper(Context context) {
        this.context = context.getApplicationContext();
    }

    public void setListener(UsbStateListener l) {
        this.listener = l;
    }

    public UsbState getCurrentState() {
        return currentState;
    }

    public void start() {
        registerReceivers();
        dispatchAppStart();
    }

    public void stop() {
        try {
            context.unregisterReceiver(receiver);
        } catch (Exception ignored) {
        }
    }

    private void dispatchAppStart() {
        handler.postDelayed(this::detectOnStartup, 1000);
    }

    private void detectOnStartup() {
        File root = UsbPathResolver.getUsbRoot(context);
        if (root != null) {
            usbRoot = root;
            setState(UsbState.STORAGE_MOUNTED);
        } else if (startupRetry < MAX_RETRY) {
            startupRetry++;
            handler.postDelayed(this::detectOnStartup, 1000);
        } else {
            setState(UsbState.NO_USB);
        }
    }

    private void onUsbAttached() {
        if (hasUsb(context)) {
            setState(UsbState.STORAGE_MOUNTED);
        } else if (currentState == UsbState.NO_USB || currentState == UsbState.INIT) {
            setState(UsbState.USB_ATTACHED);
        }
    }


    private void onUsbDetached() {
        usbRoot = null;
        setState(UsbState.NO_USB);
    }

    private void onStorageMounted() {
        File root = UsbPathResolver.getUsbRoot(context);
        if (root != null) {
            mountRetry = 0;
            usbRoot = root;
            setState(UsbState.STORAGE_MOUNTED);
        } else if (mountRetry++ < MAX_MOUNT_RETRY) {
            handler.postDelayed(this::onStorageMounted, 500);
        } else {
            setState(UsbState.USB_ATTACHED);
        }
    }

    private void onStorageRemoved() {
        usbRoot = null;
        setState(UsbState.NO_USB);
    }

    public File getUsbRoot() {
        return usbRoot;
    }

    private void setState(UsbState newState) {
        if (newState == currentState) return;
        currentState = newState;
        if (newState != UsbState.INIT) {
            startupRetry = MAX_RETRY;
        }
        if (listener != null) {
            listener.onUsbStateChanged(newState);
        }
    }

    // ===================== 广播 =====================
    private void registerReceivers() {
        IntentFilter usbFilter = new IntentFilter();
        usbFilter.addAction("android.hardware.usb.action.USB_DEVICE_ATTACHED");
        usbFilter.addAction("android.hardware.usb.action.USB_DEVICE_DETACHED");
        context.registerReceiver(receiver, usbFilter);

        IntentFilter mediaFilter = new IntentFilter();
        mediaFilter.addAction(Intent.ACTION_MEDIA_MOUNTED);
        mediaFilter.addAction(Intent.ACTION_MEDIA_REMOVED);
        mediaFilter.addAction(Intent.ACTION_MEDIA_UNMOUNTED);
        mediaFilter.addDataScheme("file");
        context.registerReceiver(receiver, mediaFilter);
    }

    private final BroadcastReceiver receiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context ctx, Intent intent) {
            String action = intent.getAction();
            Log.e(TAG, "action = " + action);
            if ("android.hardware.usb.action.USB_DEVICE_ATTACHED".equals(action)) {
                onUsbAttached();
            } else if ("android.hardware.usb.action.USB_DEVICE_DETACHED".equals(action)) {
                onUsbDetached();
            } else if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
                onStorageMounted();
            } else if (Intent.ACTION_MEDIA_REMOVED.equals(action)
                    || Intent.ACTION_MEDIA_UNMOUNTED.equals(action)) {
                onStorageRemoved();
            }
        }
    };

    public static boolean hasUsb(Context context) {
        StorageManager sm = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
        if (sm == null) return false;
        List<StorageVolume> volumes = sm.getStorageVolumes();
        for (StorageVolume v : volumes) {
            if (v.isRemovable() && v.getState().equals(Environment.MEDIA_MOUNTED)) {
                return true;
            }
        }
        return false;
    }

    @Nullable
    public static File findUpdateDir(File usbRoot) {
        if (usbRoot == null || !usbRoot.exists() || !usbRoot.isDirectory()) {
            return null;
        }
        File updata = new File(usbRoot, "update");
        if (updata.exists() && updata.isDirectory()) {
            return updata;
        }
        return null;
    }

    public enum UsbState {
        INIT,
        NO_USB,
        USB_ATTACHED,
        STORAGE_MOUNTED
    }

    public interface UsbStateListener {
        void onUsbStateChanged(UsbState newState);
    }
}


import android.content.Context;
import android.os.Environment;
import android.os.storage.StorageManager;
import android.os.storage.StorageVolume;

import java.io.File;

public final class UsbPathResolver {

    public static File getUsbRoot(Context context) {
        StorageManager sm =
                (StorageManager) context.getSystemService(Context.STORAGE_SERVICE);
        if (sm == null) return null;

        for (StorageVolume v : sm.getStorageVolumes()) {
            if (!v.isRemovable()) continue;
            if (!Environment.MEDIA_MOUNTED.equals(v.getState())) continue;

            File dir = v.getDirectory();
            if (dir != null && dir.exists()) {
                return dir;
            }

            String uuid = v.getUuid();
            if (uuid != null) {
                File f = new File("/storage/" + uuid);
                if (f.exists()) return f;
            }
        }
        return null;
    }
}
相关推荐
千里马学框架3 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台3 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone3 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc3 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo3 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077003 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼3 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone3 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen3 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone3 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui