Android跨应用数据共享:ContentProvider详解

目录

在应用之间共享数据

通过ContentProvided封装数据

[一、ContentProvider 的作用](#一、ContentProvider 的作用)

图中含义详解:

[图中两个 App:](#图中两个 App:)

[🔗 工作流程说明(图解):](#🔗 工作流程说明(图解):)

Server端代码编写:

[1. 创建 SQLite 数据库辅助类(UserDBHelper)](#1. 创建 SQLite 数据库辅助类(UserDBHelper))

[2. 创建 ContentProvider 子类(UserInfoProvider)](#2. 创建 ContentProvider 子类(UserInfoProvider))

类声明和成员变量

初始化数据库

UriMatcher:路径匹配器

[删除 delete()](#删除 delete())

[插入 insert()](#插入 insert())

[查询 query()](#查询 query())

自定义的常量类

[1. AUTHORITIES](#1. AUTHORITIES)

[2. CONTENT_URI](#2. CONTENT_URI)

[3. 字段名常量(PHONE, PSW 等)](#3. 字段名常量(PHONE, PSW 等))

[4. ID 与 BaseColumns](#4. ID 与 BaseColumns)

通过ContentResolver访问数据

常用方法

示例

[1. @SuppressLint("Range")](#1. @SuppressLint("Range"))

[2. ContentValues 的使用](#2. ContentValues 的使用)

[3. ContentResolver.insert() 插入数据](#3. ContentResolver.insert() 插入数据)

[4. ContentUris.withAppendedId() 构建带 ID 的 URI](#4. ContentUris.withAppendedId() 构建带 ID 的 URI)

[5. ContentResolver.delete() 删除数据](#5. ContentResolver.delete() 删除数据)

[6. ContentResolver.query() 查询数据](#6. ContentResolver.query() 查询数据)

[7. Cursor 获取数据](#7. Cursor 获取数据)

使用内容组件获取通讯消息

运行时动态申请权限

Lazy模式

动态权限申请的三步骤

[PermissionUtil 工具类](#PermissionUtil 工具类)

checkPermission(...)

checkGrant(...)

[PermissionLazyActivity 示例](#PermissionLazyActivity 示例)

[👉 1. 用户点击按钮](#👉 1. 用户点击按钮)

[👉 2. 检查权限是否已授权](#👉 2. 检查权限是否已授权)

[👉 3. 系统弹窗授权后进入回调](#👉 3. 系统弹窗授权后进入回调)

[👉 4. 判断用户授权结果](#👉 4. 判断用户授权结果)

Eager模式

[与 Lazy 模式对比](#与 Lazy 模式对比)

[与 Lazy 模式的实现差异点(Eager 模式)如下:](#与 Lazy 模式的实现差异点(Eager 模式)如下:)

[onCreate() 中立即申请所有权限(Eager 模式的核心特征)](#onCreate() 中立即申请所有权限(Eager 模式的核心特征))

点击按钮仍然可以保留单独请求(可选)

[重点关注 REQUEST_CODE_ALL 的权限回调逻辑](#重点关注 REQUEST_CODE_ALL 的权限回调逻辑)

利用ContResolver读写联系人

联系人是如何存储的?

添加联系人

步骤概览:

添加联系人两种方式

[什么是 RawContacts 和 Data 表](#什么是 RawContacts 和 Data 表)

方式一:逐条插入联系人

[🔧 插入步骤说明](#🔧 插入步骤说明)

示例代码要点说明

方式二:批处理插入联系人(事务推荐)

[✨ 优势](#✨ 优势)

构建流程

[1️⃣ ContentProviderOperation 是什么?](#1️⃣ ContentProviderOperation 是什么?)

[2️⃣ 什么是 withValueBackReference()?](#2️⃣ 什么是 withValueBackReference()?)

利用ContentObserve监听短信

[什么是 ContentObserver?](#什么是 ContentObserver?)

监听短信基本步骤

步骤总览:

[监听的 URI 是什么?](#监听的 URI 是什么?)

[为什么设置 true:](#为什么设置 true:)

[重写 onChange() 方法:](#重写 onChange() 方法:)

[忽略无效 uri 的判断逻辑:](#忽略无效 uri 的判断逻辑:)

[onChange 中的处理逻辑](#onChange 中的处理逻辑)

取消监听的好习惯:

如何验证接收短信?

​编辑

[监听 + 通知流程](#监听 + 通知流程)

总体原理(类比广播)

流程如下

监听方调用:

[当 ContentProvider 插入或更新数据,并调用:](#当 ContentProvider 插入或更新数据,并调用:)

系统自动回调监听器的:

在应用之间共享文件

使用相册图片发送彩信

[1. 打开系统图库选择图片](#1. 打开系统图库选择图片)

[2. 需要的权限声明](#2. 需要的权限声明)

[📌 Manifest 中声明(运行前准备):](#📌 Manifest 中声明(运行前准备):)

运行时动态申请(否则会打不开图库或选了没反应):

3.如何获取选中的图片

4.如何发送彩信?

借助FileProvider发送彩信

[为什么要用 FileProvider?](#为什么要用 FileProvider?)

一、基本原理

[二、Manifest 权限与配置准备](#二、Manifest 权限与配置准备)

[1. 添加必需权限](#1. 添加必需权限)

[2. FileProvider 注册](#2. FileProvider 注册)

详细解释:

[4. 配置 strings.xml](#4. 配置 strings.xml)

[5. 配置 file_paths.xml](#5. 配置 file_paths.xml)

[三、使用 MediaStore 加载图片的关键](#三、使用 MediaStore 加载图片的关键)

[1. MediaStore 是什么?](#1. MediaStore 是什么?)

[2. 为什么需要手动扫描文件夹?](#2. 为什么需要手动扫描文件夹?)

[3. 查询图片的常用 URI 与字段](#3. 查询图片的常用 URI 与字段)

[4. 从系统媒体库中加载符合条件的图片列表,并保存在 mImageList 中备用代码](#4. 从系统媒体库中加载符合条件的图片列表,并保存在 mImageList 中备用代码)

5.FileUtil中的checkFileUri

四、使用FileProvider发送带图片的彩信

[① 创建 URI](#① 创建 URI)

[② 使用 FileProvider 转换为 content:// URI](#② 使用 FileProvider 转换为 content:// URI)

[③ 构建 Intent](#③ 构建 Intent)

[④ 添加彩信内容](#④ 添加彩信内容)

[⑤ 启动 Intent](#⑤ 启动 Intent)

示例流程

五、借助FileProvider安装应用

[一、为什么要使用 FileProvider 安装 APK?](#一、为什么要使用 FileProvider 安装 APK?)

二、流程概览

三、关键步骤与知识点拆解

[1️⃣ 权限处理(分版本)](#1️⃣ 权限处理(分版本))

[Android 11(API 30)及以上:](#Android 11(API 30)及以上:)

[Android 10 及以下:](#Android 10 及以下:)

[2️⃣ APK 路径的构造](#2️⃣ APK 路径的构造)

[3️⃣ 校验 APK 文件是否有效](#3️⃣ 校验 APK 文件是否有效)

[4️⃣ 获取 FileProvider 的 content:// URI](#4️⃣ 获取 FileProvider 的 content:// URI)

[5️⃣ 构建安装 Intent 并启动安装器](#5️⃣ 构建安装 Intent 并启动安装器)

FileProvider总结

[一、什么是 FileProvider?](#一、什么是 FileProvider?)

[二、FileProvider 解决了什么问题?](#二、FileProvider 解决了什么问题?)

三、FileProvider如何使用?

[👉 把 File(本地路径)转换成内容 URI(content:// 格式)](#👉 把 File(本地路径)转换成内容 URI(content:// 格式))


在应用之间共享数据

通过ContentProvided封装数据

ContentProvider 是 Android 中四大组件之一(另外三个是 ActivityServiceBroadcastReceiver),它的主要作用是 在不同应用之间共享数据

一、ContentProvider 的作用

  • 实现跨应用数据共享:让一个应用的私有数据可以在授权的情况下被其他应用访问。

  • 统一数据访问接口:使用 URI 统一管理不同类型的数据(如联系人、媒体、应用私有数据库等)。

图中含义详解:
图中两个 App:
  • Server App :数据的"拥有者",它里面有数据库(SQLite)和 ContentProvider

  • Client App:数据的"使用者",它想把输入的数据存进 Server App 的数据库,或者读取已有数据。

🔗 工作流程说明(图解):
  1. Client App 输入数据(如输入姓名、手机号等);

  2. 它通过 ContentResolver(Android 提供的客户端访问接口)来调用 Server App 的 ContentProvider

  3. ContentProvider 会将这些数据传给 Server App 内部的 SQLite 数据库;

  4. 数据就这样 安全地从 Client App 写入到了 Server App 的数据库中

这个过程是 跨应用、跨进程通信

Server端代码编写:

1. 创建 SQLite 数据库辅助类(UserDBHelper)
java 复制代码
public class UserDBHelper extends SQLiteOpenHelper {


    private static final String DB_NAME = "user.db";
    private static final int DB_VERSION = 1;
    public static final String TABLE_NAME = "user";

    private static UserDBHelper mHelper = null;

    private UserDBHelper(Context context) {
        super(context,DB_NAME,null,DB_VERSION);
    }

    // 利用单列模式获取数据库帮助器的唯一实例
    public static UserDBHelper getInstance(Context context){
        if(mHelper == null) mHelper = new UserDBHelper(context);
        return mHelper;
    }
    // 创建数据库,执行建表语句
    @Override
    public void onCreate(SQLiteDatabase db) {
        String sql = "CREATE TABLE "+TABLE_NAME+" (id INTEGER PRIMARY KEY AUTOINCREMENT, phone varchar, psw varchar)";
        db.execSQL(sql);
    }

    // 版本号更新的时候执行
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // 修改版本之后,可以在这里面对表结构进行新增字段
    }
}
2. 创建 ContentProvider 子类(UserInfoProvider)

用于对用户信息表进行 增、删、查(未实现更新) 的封装操作,并提供统一的 URI 接口供其他应用或组件调用。

类声明和成员变量
java 复制代码
public class UserInfoProvider extends ContentProvider {
    private UserDBHelper dbHelper;
  • 继承自 ContentProvider,是 Android 四大组件之一,专门用于 跨应用共享数据

  • dbHelper 是一个操作 SQLite 数据库的帮助类(通常继承自 SQLiteOpenHelper)。


初始化数据库
java 复制代码
@Override
public boolean onCreate() {
    dbHelper = UserDBHelper.getInstance(getContext());
    return true;
}
  • onCreate()ContentProvider 的生命周期回调,在 Provider 第一次被访问时自动执行。

  • 使用单例模式创建 UserDBHelper 实例,避免多次打开数据库。


UriMatcher:路径匹配器
java 复制代码
private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);

private static final int USERS = 1; // 多行(例如查询全部)
private static final int USER = 2;  // 单行(例如 id=3)

static {
    URI_MATCHER.addURI(UserInfoContent.AUTHORITIES,"/user",USERS);
    URI_MATCHER.addURI(UserInfoContent.AUTHORITIES,"/user/#",USER);
}
  • UriMatcher 是 Android 提供的工具类,用于根据 URI 进行规则匹配。

  • AUTHORITY 是定义的提供者标识符,例如:com.example.chapter04_server.provider.UserInfoProvider

  • /user 代表查询所有数据;

  • /user/# 代表指定 id 的单条数据(# 是数字通配符);

🔗 举例 URI:

bash 复制代码
content://com.example.chapter04_server.provider.UserInfoProvider/user     // 匹配 USERS
content://com.example.chapter04_server.provider.UserInfoProvider/user/3   // 匹配 USER

删除 delete()
java 复制代码
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
    int count = 0;
    switch (URI_MATCHER.match(uri)) {
        case USERS:
            SQLiteDatabase db1 = dbHelper.getWritableDatabase();
            count = db1.delete(UserDBHelper.TABLE_NAME, selection, selectionArgs);
            db1.close();
            break;
        case USER:
            String id = uri.getLastPathSegment(); // 取出最后的 id
            SQLiteDatabase db2 = dbHelper.getWritableDatabase();
            db2.delete(UserDBHelper.TABLE_NAME, "id=?", new String[]{id});
            break;
    }
    return count;
}
  • 根据 URI 是删除多条(USERS)还是单条(USER)记录来区分操作。

  • getLastPathSegment() 取 URI 的最后部分(即 id 值)。


插入 insert()
java 复制代码
 @Override
    public Uri insert(Uri uri, ContentValues values) {
        Log.d("ning", "UserInfoProvider的insert");

        if (URI_MATCHER.match(uri) == USERS) {
            SQLiteDatabase db = dbHelper.getWritableDatabase();
            long rowId = db.insert(UserDBHelper.TABLE_NAME, null, values);

            if (rowId > 0) {
                Uri insertedUri = ContentUris.withAppendedId(uri, rowId);
                return insertedUri;
            }
            return null; // 插入失败
        }
        return null;
    }
  • 当 URI 匹配 USER 时插入一条记录。

  • ContentValues 是键值对形式的数据对象,用于传入字段和值。

🔍 注意:

  • 你这里只允许插入单条数据(/user/#),通常插入操作应该也允许 /user 类型更合理。

查询 query()
java 复制代码
@Override
public Cursor query(Uri uri, String[] projection, String selection,
                    String[] selectionArgs, String sortOrder) {
    Log.d("ning","UserInfoProvider的query");
    if (URI_MATCHER.match(uri) == USERS){
        SQLiteDatabase db = dbHelper.getReadableDatabase();
        return db.query(UserDBHelper.TABLE_NAME, projection, selection, selectionArgs, null, null, null);
    }
    return null;
}
  • 实现了多行查询 USERS(即 /user),返回 Cursor 游标对象。
自定义的常量类

封装和 ContentProvider 打交道时用到的常量,避免写死字符串,方便统一管理和维护。

java 复制代码
public class UserInfoContent implements BaseColumns {
    // 1. 定义 Provider 的唯一 authority
    public static final String AUTHORITIES = "com.example.chapter04_server.provider.UserInfoProvider";

    // 2. 定义访问整个 user 表的 URI
    public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITIES + "/user");

    // 3. 定义字段名常量
    public static final String PHONE = "phone";
    public static final String PSW = "psw";

    // 4. 定义主键字段名(如果使用了 BaseColumns,这个字段通常是 _id)
    public static final String ID = "id";
}
1. AUTHORITIES
  • 与 AndroidManifest.xml 中注册的 <provider android:authorities="..."/> 保持一致。

  • 是 ContentProvider 唯一的标识符。

2. CONTENT_URI
  • 是你访问数据的入口 URI(可类比于 RESTful 的 API 路径)。

  • /user 是表名或者你想要暴露的数据路径。

  • 可以用来进行增删改查,比如:getContentResolver().insert(UserInfoContent.CONTENT_URI, values);

3. 字段名常量(PHONE, PSW 等)
  • 用于数据库字段名的统一引用。

  • 可避免硬编码 "phone""psw" 等字符串,减少出错率,便于自动补全。

4. ID 与 BaseColumns
  • BaseColumns 是 Android 提供的接口,会自动为你提供两个字段名:

    • _ID(等价于 " _id",数据库主键)

    • _COUNT(记录总数,可选)

所以你如果继承了 BaseColumns,建议你的数据库表主键字段就用 _id,否则系统组件如 CursorAdapterLoader 等无法正常识别。

通过ContentResolver访问数据

ContentResolver 是 Android 提供的统一接口,用于在应用之间访问数据,它作为桥梁帮助我们访问由 ContentProvider 提供的数据

你可以通过 ContentResolver 实现跨应用的数据操作,如联系人、短信、媒体、第三方App数据等。

概念 类比角色 说明
ContentProvider 数据服务窗口 是数据的"管理者",对外提供统一的数据访问接口(CURD 操作)
ContentResolver 顾客/访问者 是"客户端",通过它来访问 ContentProvider 提供的数据
常用方法
方法 功能 返回值
insert(Uri, ContentValues) 插入数据 Uri(插入项的 URI)
delete(Uri, String, String[]) 删除数据 int(删除的行数)
update(Uri, ContentValues, String, String[]) 更新数据 int(更新的行数)
query(Uri, String[], String, String[], String) 查询数据 Curs
示例
java 复制代码
    @SuppressLint("Range") // 不用提示
    @Override
    public void onClick(View v) {
        if(v.getId() == R.id.btn_add){
            ContentValues values = new ContentValues();
            values.put(UserInfoContent.PHONE,et_phone.getText().toString());
            values.put(UserInfoContent.PSW,et_password.getText().toString());
            getContentResolver().insert(UserInfoContent.CONTENT_URI,values);
            Toast.makeText(this,"新增用户成功",Toast.LENGTH_SHORT).show();
        }else if(v.getId() == R.id.btn_delete){
            // 删除单行
            Uri uri = ContentUris.withAppendedId(UserInfoContent.CONTENT_URI, 2);
            int count =getContentResolver().delete(uri,"phone=?",new String[]{et_phone.getText().toString()});
            // 删除多行
//            int count =getContentResolver().delete(UserInfoContent.CONTENT_URI,"phone=?",new String[]{et_phone.getText().toString()});
             if(count>0){
                 Toast.makeText(this,"删除用户成功",Toast.LENGTH_SHORT).show();
             }
        }else if(v.getId() == R.id.btn_update){
                Toast.makeText(this,"修改用户成功",Toast.LENGTH_SHORT).show();
        } else if(v.getId() == R.id.btn_select){
            Cursor cursor = getContentResolver().query(UserInfoContent.CONTENT_URI, null, null, null);
            if(cursor != null){
                while(cursor.moveToNext()){
                    User user = new User();
//                    user.setId(cursor.getInt(cursor.getColumnIndex(UserInfoContent._ID)));
                    user.setId(cursor.getInt(cursor.getColumnIndex(UserInfoContent.ID)));
                    user.setPhone(cursor.getString(cursor.getColumnIndex("phone")));
                    user.setPsw(cursor.getString(cursor.getColumnIndex("psw")));
                    Log.d("ning",user.toString());
                }
            }
            cursor.close();
            Toast.makeText(this,"查询所有用户成功",Toast.LENGTH_SHORT).show();
        }
    }
1. @SuppressLint("Range")

作用:

告诉编译器"忽略"关于 Cursor.getColumnIndex() 使用字符串列名带来的潜在风险警告(比如列名可能拼写错误等)。

背景:

从 Android API 30 开始,Google 推荐使用 getColumnIndexOrThrow() 或强类型方式,getColumnIndex("xxx") 会提示警告。


2. ContentValues 的使用

作用:

构造一个键值对的对象,用于向 ContentProvider 插入或更新数据。

java 复制代码
ContentValues values = new ContentValues();
values.put("phone", "123456789");
values.put("psw", "abc123");

类似于 Map,专用于数据库操作。


3. ContentResolver.insert() 插入数据
java 复制代码
getContentResolver().insert(UserInfoContent.CONTENT_URI, values);

作用:

通过系统的 ContentResolver 调用目标 ContentProviderinsert() 方法,把一行数据插入数据库。


4. ContentUris.withAppendedId() 构建带 ID 的 URI
java 复制代码
Uri uri = ContentUris.withAppendedId(UserInfoContent.CONTENT_URI, 2);

作用:

给基础 URI 添加一个路径段(如 ID),生成一个具体资源 URI,例如:

bash 复制代码
content://com.example.provider/user/2

用于针对特定记录操作(如删除单行)。


5. ContentResolver.delete() 删除数据
java 复制代码
getContentResolver().delete(uri, "phone=?", new String[]{...});
  • uri: 要操作的资源(数据表或单条数据)

  • "phone=?": where 子句

  • new String[]{...}: 替代问号的参数

6. ContentResolver.query() 查询数据
  • URI:查询的目标资源

  • projection:查询哪些列(null 表示全部)

  • selection / selectionArgs:筛选条件

  • sortOrder:排序规则

返回的是 Cursor 结果集。


7. Cursor 获取数据
  • Cursor 是一张"游标表",指向数据库的查询结果。

  • moveToNext():移动到下一行。

  • getColumnIndex(...):获取列索引。

  • getInt() / getString():读取指定列的数据。

使用内容组件获取通讯消息

运行时动态申请权限

Lazy模式

Lazy 的思想:不是一开始就加载或执行所有功能,而是"用到再申请 / 用到再处理"。

在权限申请中,这种懒加载体现在:

  • App 启动时不立刻申请所有权限;

  • 而是当用户点击"读写通讯录"或"收发短信"等按钮时,才动态去检查/申请对应权限

动态权限申请的三步骤
步骤 方法 作用
第一步 ContextCompat.checkSelfPermission 检查权限是否已授权
第二步 ActivityCompat.requestPermissions 请求权限(系统弹窗)
第三步 onRequestPermissionsResult 获取用户的授权结果
PermissionUtil 工具类

这是一个用于封装权限判断与请求的实用工具类。

java 复制代码
public class PermissionUtil {

    // 检查多个权限,返回 true 表示权限都已授权,false 表示需请求权限
    public static boolean checkPermission(Activity atc, String[] permissions, int requestCode) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            int check = PackageManager.PERMISSION_GRANTED;
            for (String permission : permissions) {
                check = ContextCompat.checkSelfPermission(atc, permission);
                if (check != PackageManager.PERMISSION_GRANTED) break;
            }
            if (check != PackageManager.PERMISSION_GRANTED) {
                ActivityCompat.requestPermissions(atc, permissions, requestCode);
                return false;
            }
        }
        return true;
    }

    // 判断权限请求结果是否全部授权
    public static boolean checkGrant(int[] grantResults) {
        if (grantResults != null) {
            for (int result : grantResults) {
                if (result != PackageManager.PERMISSION_GRANTED) {
                    return false;
                }
            }
            return true;
        }
        return false;
    }
}
方法名 功能 何时调用
checkPermission 检查权限并请求 在点击按钮时调用,发起权限请求
checkGrant 判断权限结果 在回调 onRequestPermissionsResult 中调用
checkPermission(...)

功能:

检查一组权限是否已全部授权,如果有权限未授权,则自动弹出系统授权请求。

逻辑简述:

  1. 判断系统版本是否为 Android 6.0+;

  2. 遍历权限数组,查看是否全部已授权;

  3. 如果有未授权的权限,调用 requestPermissions 弹出授权对话框;

  4. 返回值:

    • true:权限已全部授予;

    • false:权限未授予,已发起授权请求。


checkGrant(...)

功能:

在权限申请回调中,判断用户是否授权成功。

逻辑简述:

  1. 遍历 grantResults 数组;

  2. 只要有一个结果不为 PERMISSION_GRANTED,就表示授权失败;

  3. 返回值:

    • true:用户全部授权;

    • false:用户至少拒绝了一个权限或结果为空。

PermissionLazyActivity 示例

点击按钮时才申请权限的典型 Activity。

权限列表

java 复制代码
// 通讯录读写权限
private static final String[] PERMISSIONS_CONTACTS = {
    Manifest.permission.READ_CONTACTS,
    Manifest.permission.WRITE_CONTACTS
};

// 短信权限
private static final String[] PERMISSIONS_SMS = {
    Manifest.permission.SEND_SMS,
    Manifest.permission.READ_SMS
};

核心逻辑实现

java 复制代码
public class PermissionLazyActivity extends AppCompatActivity implements View.OnClickListener {

    private static final int REQUEST_CODE_CONTACTS = 1;
    private static final int REQUEST_CODE_SMS = 2;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_permission_lazy);

        findViewById(R.id.btn_contact).setOnClickListener(this);
        findViewById(R.id.btn_sms).setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.btn_contact) {
            PermissionUtil.checkPermission(this, PERMISSIONS_CONTACTS, REQUEST_CODE_CONTACTS);
        } else if (v.getId() == R.id.btn_sms) {
            PermissionUtil.checkPermission(this, PERMISSIONS_SMS, REQUEST_CODE_SMS);
        }
    }

        // 用户是否同意
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        switch (requestCode){
            case REQUEST_CODE_CONTACTS:
                if(PermissionUtil.checkGrant(grantResults)){
                    Toast.makeText(this,"获取通讯录读写权限成功", Toast.LENGTH_SHORT).show();
                    Log.d("ning","获取通讯录读写权限成功");
                }else{
                    Log.d("ning","获取通讯录读写权限失败");
                    Toast.makeText(this,"获取通讯录读写权限失败", Toast.LENGTH_SHORT).show();
                    jumpToSettings();
                }
                break;
            case REQUEST_CODE_SMS:
                if(PermissionUtil.checkGrant(grantResults)){
                    Log.d("ning","获取短信读写权限成功");
                }else  {
                    Toast.makeText(this,"获取短信读写权限失败", Toast.LENGTH_SHORT).show();
                    Log.d("ning","获取短信读写权限失败");
                    jumpToSettings();
                }
                break;
        }
    }


    // 跳转系统设置页引导用户手动开启权限
    private void jumpToSettings() {
        Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
        intent.setData(Uri.fromParts("package", getPackageName(), null));
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        startActivity(intent);
    }
}

👉 1. 用户点击按钮

根据按钮的 ID,判断用户点击的是哪个功能(通讯录 or 短信)。

👉 2. 检查权限是否已授权

使用工具类 PermissionUtil.checkPermission(...) 检查对应权限组是否全部授权:

  • 若已授权:返回 true,可直接执行功能;

  • 若未授权:自动弹出权限请求框,并返回 false

👉 3. 系统弹窗授权后进入回调

当用户在弹窗中做出选择后,系统会调用:

java 复制代码
onRequestPermissionsResult(requestCode, permissions, grantResults)

👉 4. 判断用户授权结果

在回调中使用 PermissionUtil.checkGrant(...) 判断授权结果数组:

  • 若用户授权成功:输出成功日志或执行功能;

  • 若用户拒绝授权:提示失败,并跳转到系统设置页,引导手动授权。

注意:还需要在清单文件当中进行权限声明

XML 复制代码
<!-- 明确声明短信功能是可选的,防止权限请求被系统忽略 -->
    <uses-feature android:name="android.hardware.telephony" android:required="false"/>

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

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

Eager模式

在应用启动阶段(如 SplashActivity、MainActivity 的 onCreate() 中),提前一次性申请应用可能使用到的所有危险权限,而不是等用户操作到对应功能时再请求。

与 Lazy 模式对比
对比项 Eager 模式 Lazy 模式
权限申请时机 应用启动时立即申请所有可能用到的权限 用户点击功能时再按需申请
用户体验 差,权限弹窗密集且无上下文 好,用户知道为何请求权限
审核友好度 低,易被拒 高,符合"权限最小化"原则
实现复杂度 简单 稍高,需要在多个入口处理权限
与 Lazy 模式的实现差异点(Eager 模式)如下:
java 复制代码
    // 读写权限
   private static final String[] PERMISSIONS = new String[]{
            Manifest.permission.READ_CONTACTS,
            Manifest.permission.WRITE_CONTACTS,
            Manifest.permission.SEND_SMS,
            Manifest.permission.READ_SMS
    };

   private static final int REQUEST_CODE_ALL=1;
   private static final int REQUEST_CODE_CONTACTS=2;
   private static final int REQUEST_CODE_SMS=3;

PERMISSIONS 把四个"危险权限"一次性准备好,供:

  • Eager 模式 一次性请求时使用(如 checkPermission(this, PERMISSIONS, REQUEST_CODE_ALL)

REQUEST_CODE_XXX 常量:用于标记是哪一类权限请求

权限请求时我们需要一个 requestCode,以便在 onRequestPermissionsResult() 中判断是哪个请求返回:

常量名 用途
REQUEST_CODE_ALL 1 表示"所有权限一起申请"的请求(Eager模式)
REQUEST_CODE_CONTACTS 2 表示"仅申请通讯录权限"的请求(Lazy模式)
REQUEST_CODE_SMS 3 表示"仅申请短信权限"的请求(Lazy模式)
onCreate() 中立即申请所有权限(Eager 模式的核心特征
java 复制代码
// Lazy 模式中不会有这段代码
// Eager 模式中添加:
PermissionUtil.checkPermission(this, PERMISSIONS, REQUEST_CODE_ALL);
  • 📌 Lazy 模式中,仅在点击按钮后才调用 checkPermission

  • ✅ Eager 模式中在 onCreate() 阶段就统一申请


点击按钮仍然可以保留单独请求(可选)
  • 在 Eager 模式中,点击按钮再申请权限用于容错:
java 复制代码
// 若保留以下内容,则是"Eager + Lazy混合式",但 Eager 已抢先申请
PermissionUtil.checkPermission(this, new String[]{PERMISSIONS[0], PERMISSIONS[1]}, REQUEST_CODE_CONTACTS);

重点关注 REQUEST_CODE_ALL 的权限回调逻辑
  • onRequestPermissionsResult() 中对 REQUEST_CODE_ALL 的处理逻辑要详尽,因为所有权限一次性集中返回:
java 复制代码
case REQUEST_CODE_ALL:
    if (PermissionUtil.checkGrant(grantResults)) {
        Log.d("ning", "所有权限获取成功");
    } else {
        // 部分权限获取失败
        for (int i = 0; i < grantResults.length; i++) {
            if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
                // 根据索引 i 在 permissions 数组中判断失败的权限
                switch (permissions[i]) {
                    case Manifest.permission.READ_CONTACTS:
                    case Manifest.permission.WRITE_CONTACTS:
                        Log.d("ning", "获取通讯录读写权限失败");
                        jumpToSettings();
                        break;

                    case Manifest.permission.SEND_SMS:
                    case Manifest.permission.READ_SMS:
                        Log.d("ning", "获取短信读写权限失败");
                        jumpToSettings();
                        break;
                }
            }
        }
    }
    break;

回调参数说明

  • permissions[]:你申请的每一个权限名称(字符串数组)

  • grantResults[]:和 permissions[] 一一对应,表示每个权限的授予结果

permissions[i] grantResults[i]
Manifest.permission.READ_CONTACTS PERMISSION_GRANTED 或 PERMISSION_DENIED
Manifest.permission.SEND_SMS PERMISSION_GRANTED 或 PERMISSION_DENIED

使用 for (int i) 遍历 grantResults,结合 permissions[i] 的值,可以逐个判断是哪项权限失败了 ,从而做出分类处理或提示用户去设置页打开权限。

遍历所有权限结果

java 复制代码
for (int i = 0; i < grantResults.length; i++)

判断当前第 i 个权限是否被拒绝

java 复制代码
if (grantResults[i] != PackageManager.PERMISSION_GRANTED)

查找是哪一个权限失败

  • 通过 permissions[i] 取出当前这项权限的字符串值

  • 使用 switch 判断具体是哪个权限失败

  • 再通过日志/跳转设置页/弹窗等方式提示用户

利用ContResolver读写联系人

联系人是如何存储的?

联系人数据库主要涉及两个 URI:

  • ContactsContract.RawContacts.CONTENT_URI:插入新联系人用。

  • ContactsContract.Data.CONTENT_URI:写入联系人详细数据(姓名、电话、邮箱等)。

添加联系人

步骤概览:
  1. 插入一条 raw contact。

  2. 根据这条 raw contact 的 ID 添加姓名。

  3. 添加电话、邮箱等信息。

添加联系人两种方式
  • 方式一:多次调用 ContentResolver 插入数据(非事务)

  • 方式二:批处理操作 applyBatch(事务方式,推荐)

URI 是关键桥梁,例如:

  • 插入联系人用 ContactsContract.RawContacts.CONTENT_URI

  • 写入数据用 ContactsContract.Data.CONTENT_URI

什么是 RawContacts 和 Data 表
表名 作用
RawContacts 存放联系人主体信息(ID)
Data 存放具体字段,如姓名、电话、邮箱等,每一条都有 MIME 类型指明字段类型

Data 表是联系人的核心信息表 ,它是个多态表,不同数据(电话、邮箱、姓名等)共用同一张表,字段复用。主要靠两点区分:

字段 说明
MIMETYPE 指明这一行记录的数据类型(如电话、姓名、邮箱等)
DATA1 ~ DATA15 存储不同类型的具体数据,每种类型的字段映射不同
数据类型(MIMETYPE) 对应类 常用字段 字段意义
StructuredName.CONTENT_ITEM_TYPE 姓名 DATA1 = display name,DATA2 = given name(名)DATA3 = family name(姓) 如果你只设置显示名,直接用 DATA1
Phone.CONTENT_ITEM_TYPE 电话 DATA1 = 电话号码,DATA2 = 电话类型(TYPE_MOBILE等) 电话必须写 DATA1
Email.CONTENT_ITEM_TYPE 邮箱 DATA1 = 邮箱地址。DATA2 = 邮箱类型(TYPE_WORK等) 邮箱地址也放在 DATA1
方式一:逐条插入联系人
🔧 插入步骤说明
  1. 插入空 RawContact 得到 raw_contact_id

  2. 插入姓名(MIME_TYPE 为 StructuredName

  3. 插入电话(MIME_TYPE 为 Phone

  4. 插入邮箱(MIME_TYPE 为 Email

java 复制代码
/**
     * 往手机通讯录添加一个联系人信息(包括姓名、电话号码、电子邮箱)。
     * 此方法通过 ContentResolver 向 ContactsContract.RawContacts 和 ContactsContract.Data 表中插入数据,
     * 实现一个完整联系人的构建。
     *
     * @param resolver ContentResolver 用于访问系统通讯录数据库
     * @param contact  包含联系人姓名、电话和邮箱的对象
     */
    private void addContacts(ContentResolver resolver, Contact contact) {
        // ---------- 步骤一:向 RawContacts 表插入空值,创建一个新的联系人 ----------
        ContentValues values = new ContentValues();
        // 插入空值,并获取系统分配的 raw_contact_id(唯一联系人ID)
        Uri uri = resolver.insert(ContactsContract.RawContacts.CONTENT_URI, values);
        long rawContactId = ContentUris.parseId(uri); // 获取返回的 ID

        // ---------- 步骤二:向 Data 表插入姓名 ----------
        ContentValues name = new ContentValues();
        name.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId); // 关联 raw_contact_id
        name.put(Contacts.Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE); // 指定数据类型为"姓名"
        name.put(Contacts.Data.DATA2, contact.name); // DATA2 表示给出的名字字段
        resolver.insert(ContactsContract.Data.CONTENT_URI, name); // 插入姓名记录

        // ---------- 步骤三:向 Data 表插入电话号码 ----------
        ContentValues phone = new ContentValues();
        phone.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId); // 关联 raw_contact_id
        phone.put(Contacts.Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE); // 指定数据类型为"电话"
        phone.put(Contacts.Data.DATA1, contact.phone); // DATA1 为电话号码
        phone.put(Contacts.Data.DATA2, CommonDataKinds.Phone.TYPE_MOBILE); // DATA2 为电话号码类型(手机)
        resolver.insert(ContactsContract.Data.CONTENT_URI, phone); // 插入电话记录

        // ---------- 步骤四:向 Data 表插入电子邮箱 ----------
        ContentValues email = new ContentValues();
        email.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId); // 关联 raw_contact_id
        email.put(Contacts.Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE); // 指定数据类型为"邮箱"
        email.put(Contacts.Data.DATA1, contact.email); // DATA1 为邮箱地址
        email.put(Contacts.Data.DATA2, CommonDataKinds.Email.TYPE_WORK); // DATA2 为邮箱类型(工作邮箱)
        resolver.insert(ContactsContract.Data.CONTENT_URI, email); // 插入邮箱记录
    }
示例代码要点说明
java 复制代码
Uri uri = resolver.insert(ContactsContract.RawContacts.CONTENT_URI, new ContentValues());
long rawContactId = ContentUris.parseId(uri); // 获取联系人 ID

每个字段都通过 .put(RAW_CONTACT_ID, id) 与这个联系人绑定。

方式二:批处理插入联系人(事务推荐)
✨ 优势
  • 原子性:要么都成功,要么都失败,避免数据残留。

  • 性能好:一次批量提交,系统只调一次数据库。

构建流程
  1. 每一个 ContentProviderOperation 表示一个插入动作

  2. withValueBackReference 用于引用第一步插入返回的 ID

  3. 最后调用 applyBatch() 执行事务

java 复制代码
contentResolver.applyBatch(ContactsContract.AUTHORITY, operations);

该方法用于一次性向手机通讯录中添加一个完整联系人 (包括姓名、电话、邮箱),采用了**批处理事务(applyBatch)**方式。

java 复制代码
  private void addFullContacts(ContentResolver contentResolver, Contact contact) {
        // 插入 raw_contact 表(只要空值即可)
        ContentProviderOperation op_main = ContentProviderOperation
                .newInsert(ContactsContract.RawContacts.CONTENT_URI)
                .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
                .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
                .build();

        // 插入姓名
        ContentProviderOperation op_name = ContentProviderOperation
                .newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
                .withValue(Contacts.Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
                .withValue(Contacts.Data.DATA1, contact.name) // display name
                .build();

        // 插入电话
        ContentProviderOperation op_phone = ContentProviderOperation
                .newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
                .withValue(Contacts.Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
                .withValue(Contacts.Data.DATA1, contact.phone)
                .withValue(Contacts.Data.DATA2, CommonDataKinds.Phone.TYPE_MOBILE)
                .build();

        // 插入邮箱
        ContentProviderOperation op_email = ContentProviderOperation
                .newInsert(ContactsContract.Data.CONTENT_URI)
                .withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
                .withValue(Contacts.Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE)
                .withValue(Contacts.Data.DATA1, contact.email)
                .withValue(Contacts.Data.DATA2, CommonDataKinds.Email.TYPE_WORK)
                .build();

        ArrayList<ContentProviderOperation> operationList = new ArrayList<>();
        operationList.add(op_main);
        operationList.add(op_name);
        operationList.add(op_phone);
        operationList.add(op_email);

        try {
            contentResolver.applyBatch(ContactsContract.AUTHORITY, operationList);
        } catch (OperationApplicationException | RemoteException e) {
            throw new RuntimeException("添加联系人失败", e);
        }
    }

1️⃣ ContentProviderOperation 是什么?

它是一个操作对象,表示你要对某个表执行"插入 / 更新 / 删除"动作。你可以把多个操作打包成一个集合,最后统一执行,确保事务一致性。

  • 插入联系人时会涉及两个表:

    • RawContacts.CONTENT_URI:插入联系人主体,获取 ID

    • Data.CONTENT_URI:插入联系人具体信息(如姓名、电话、邮箱)


2️⃣ 什么是 withValueBackReference()

这个方法的作用是:

让后续操作引用前一个插入操作的返回 ID(raw_contact_id)

java 复制代码
.withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)

注意,

在插入联系人到 RawContacts 表时,一定要加上以下代码不然会插入失败

java 复制代码
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) 
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)

这是告诉系统"我要把联系人存储到本地账户,而不是某个同步账户(如 Google、Exchange)中"。

如果你不加,部分手机或 ROM(尤其国产 ROM)可能会插入失败或联系人不显示,因为系统无法判断该联系人属于哪个账户类型。

利用ContentObserve监听短信

什么是 ContentObserver

  • Android 提供的内容监听器。

  • 可以用来监听数据库表或系统内容的变化,如:短信、联系人、通话记录等。

  • 一旦监听的内容发生变化,会触发 onChange() 方法。

监听短信基本步骤

步骤总览:
  1. 创建 ContentObserver 子类,并重写 onChange() 方法。

  2. 使用 registerContentObserver() 注册监听 content://sms

  3. onDestroy() 中注销监听。

java 复制代码
public class MonitorSmsActivity extends AppCompatActivity {

    private SmsGetObserver mObserver;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_monitor_sms);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });
        // 给指定Uri注册内容观察器,一旦发生数据变化,就触发观察器的onChange方法
        Uri uri = Uri.parse("content://sms");
        // notifyForDescendents:
        // false:表示精确匹配,即只匹配该Uri,true:表示可以同时匹配其派生的Uri
        // 假设UriMatcher 里注册的Uri共有一下类型:
        // 1.content://AUTHORITIES/table
        // 2.content://AUTHORITIES/table/#
        // 3.content://AUTHORITIES/table/subtable
        // 假设我们当前需要观察的Uri为content://AUTHORITIES/student:
        // 如果发生数据变化的 Uri 为 3。
        // 当notifyForDescendents为false,那么该ContentObserver会监听不到,但是当notifyForDescendents
        mObserver = new SmsGetObserver(this);
        // 注册
        getContentResolver().registerContentObserver(uri, true, mObserver);

    }

    private static class SmsGetObserver extends ContentObserver{
        private final Context mContext;

        public SmsGetObserver(Context context){
            super(new Handler(Looper.getMainLooper()));
            this.mContext = context;
        }

        // 收到短信
        // onChange会多次调用,收到一条短信会调用两次onChange
        // mUri===content://sms/raw/20
        // mUri===content://sms/inbox/20
        // 安卓7.0以上系统,点击标记为已读,也会调用一次
        // mUri===content://sms
        //收到一条短信都是uri后面都会有确定的一个数字,对应数据库的id,比如上面的20
        @Override
        @SuppressLint("Range")
        public void onChange(boolean selfChange, @Nullable Uri uri) {
            super.onChange(selfChange, uri);
            if(uri == null){
                return;
            }

            if(uri.toString().contains("content://sms/raw") ||
                    uri.toString().equals("content://sms") ){
                return;
            }
            // 通过内容解析器获取符合条件的结果集游标
            Cursor cursor = mContext.getContentResolver().query(uri, new String[]{"address", "body", "date"}, null, null, "date desc");
            // 根据日期排序拿到最新的那一条
            if(cursor.moveToNext()){
                // 短信的发送号码
                String sender = cursor.getString(cursor.getColumnIndex("address"));
                // 短信内容
                String content = cursor.getString(cursor.getColumnIndex("body"));
                Log.d("ning",String.format("sender:%s,content:%s",sender,content));
            }
            cursor.close();
        }

    }

    @Override
    protected void onDestroy() {
        // 取消注册
        getContentResolver().unregisterContentObserver(mObserver);
        super.onDestroy();
    }
}

监听的 URI 是什么?

java 复制代码
Uri uri = Uri.parse("content://sms");
  • content://sms 是系统短信数据库的路径,表示监听所有短信数据表(如收件箱、草稿箱、发件箱)。

  • 等价于访问短信数据库表 sms

为什么设置 true

java 复制代码
getContentResolver().registerContentObserver(uri, true, mObserver);
参数 含义
true 监听所有子路径,如 content://sms/inbox/20content://sms/raw/21
false 只监听 content://sms 本身,无法获取具体短信变化

重写 onChange() 方法:

java 复制代码
public void onChange(boolean selfChange, @Nullable Uri uri)
  • uri 表示发生变化的具体内容地址。

  • 监听短信时,一条新短信可能触发两次:

    • 一次是 content://sms/raw/xx(临时存储)

    • 一次是 content://sms/inbox/xx(正式入库)

  • 安卓 7.0+ 标记已读也会触发一次 onChange

忽略无效 uri 的判断逻辑:

java 复制代码
if(uri.toString().contains("content://sms/raw") || uri.toString().equals("content://sms")){
    return;
}
  • sms/raw 是临时记录(短信还没完全写入)

  • sms 是总表,变化太频繁

  • 所以你只处理 sms/inbox/xxx 等真正落库的 URI 更稳妥

onChange 中的处理逻辑

java 复制代码
// 通过内容解析器获取符合条件的结果集游标
            Cursor cursor = mContext.getContentResolver().query(uri, new String[]{"address", "body", "date"}, null, null, "date desc");
            // 根据日期排序拿到最新的那一条
            if(cursor.moveToNext()){
                // 短信的发送号码
                String sender = cursor.getString(cursor.getColumnIndex("address"));
                // 短信内容
                String content = cursor.getString(cursor.getColumnIndex("body"));
                Log.d("ning",String.format("sender:%s,content:%s",sender,content));
            }
            cursor.close();
字段 含义
address 发件人手机号
body 短信内容
date 发送时间

可以按 date desc 排序,拿到最新的短信信息

取消监听的好习惯:

java 复制代码
@Override
protected void onDestroy() {
    getContentResolver().unregisterContentObserver(mObserver);
    super.onDestroy();
}

防止内存泄漏或误监听,及时解绑 ContentObserver

如何验证接收短信?

监听 + 通知流程

总体原理(类比广播)
做了什么 说明
监听者 注册了 ContentObserver 去监听某个 URI "我关心这个数据变化,请通知我"
数据提供者(如 ContentProvider) 在数据变化后调用 notifyChange() "我这边数据变了,通知所有监听我 URI 的人"
系统 找到匹配的观察器并回调它的 onChange() "你监听的数据变了,来处理吧!"
流程如下
监听方调用:
java 复制代码
getContentResolver().registerContentObserver(uri, true, observer);

表示:"我想监听这个 uri 数据有没有变化"。

当 ContentProvider 插入或更新数据,并调用:
java 复制代码
getContext().getContentResolver().notifyChange(uri, null);

表示:"告诉大家,这个 URI 的数据变了!"

系统自动回调监听器的:
java 复制代码
@Override
public void onChange(boolean selfChange, Uri uri) {
    // 你在这里处理变化(比如更新 UI)
}

在应用之间共享文件

使用相册图片发送彩信

彩信(MMS)支持的媒体附件包括图片、视频、音频、文本。你可以选择相册图片后,通过彩信发送:

1. 打开系统图库选择图片

ActivityResultLauncher + StartActivityForResult 新写法来处理"打开相册并获取用户选择的图片"

java 复制代码
ActivityResultLauncher<Intent> resultLauncher = registerForActivityResult(
    new ActivityResultContracts.StartActivityForResult(), new ActivityResultCallback<ActivityResult>() {
        // 回调函数
        @Override
        public void onActivityResult(ActivityResult result) {
            if (result.getResultCode() == RESULT_OK) {
                Uri imageUri = result.getData().getData();
                // 这里就可以使用 imageUri 显示或上传了
            }
        }
    }
);

2. 需要的权限声明

📌 Manifest 中声明(运行前准备):
java 复制代码
<!-- Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Android 12 及以下 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
运行时动态申请(否则会打不开图库或选了没反应):
java 复制代码
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES)
        != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_MEDIA_IMAGES}, 1);
    }
} else {
    if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
        != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
    }
}

3.如何获取选中的图片

java 复制代码
Uri imageUri = result.getData().getData(); // content:// 开头的 Uri
imageView.setImageURI(imageUri); // 可直接显示

4.如何发送彩信?

  • 构造 Intent.ACTION_SEND

  • 设置 setType("image/*")

  • 添加 Intent.EXTRA_STREAM(即图片的 URI)

  • 添加 addresssms_body 等字段

  • 一定要加:FLAG_GRANT_READ_URI_PERMISSION,发送文件 URI 时一定要授权,否则接收方无法读取

java 复制代码
// 发送带图片的彩信
    private void sendMms(String phone, String name) {
        Intent intent = new Intent(Intent.ACTION_SEND);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        // Intent的接受者将被准许读取Intent携带的URI数据
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        //	收件人号码
        intent.putExtra("address",phone);
        // 标题
        intent.putExtra("subject",name);
        // 正文
        intent.putExtra("sms_body","彩信正文");
        // 附件
        intent.putExtra(Intent.EXTRA_STREAM,picData);
        //彩信的附件类型为图片
        intent.setType("image/*");
        startActivity(intent);
        Toast.makeText(this,"请在弹窗中选择短信或者信息应用",Toast.LENGTH_SHORT);
    }

借助FileProvider发送彩信

为什么要用 FileProvider?

从 Android 7.0(API 24)起,直接通过 file:// URI 共享文件会抛出 FileUriExposedException,因为系统不再允许向外部应用暴露私有文件路径。必须使用 content:// URI ------ 这正是 FileProvider 提供的作用:

FileProvider 的功能:

  • 将 app 私有目录中的文件转换为 content:// URI;

  • 通过授权方式允许其他 app 访问指定的文件;

  • 提高应用的安全性与兼容性。

一、基本原理

发送彩信的目标是通过 Intent 触发系统短信应用,携带文字和图片:

java 复制代码
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("image/*");
intent.putExtra(Intent.EXTRA_STREAM, imageUri); // 图片 Uri
intent.putExtra("address", phoneNumber);        // 收件人
intent.putExtra("sms_body", messageText);       // 彩信内容
intent.setPackage("com.android.mms");           // 设定使用默认短信应用
startActivity(intent);

重点在于: imageUri 需要是通过 FileProvider 转换的 content:// URI。

二、Manifest 权限与配置准备

1. 添加必需权限
XML 复制代码
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
  • SEND_SMS:发送短信或彩信必须权限

  • READ_MEDIA_IMAGES:Android 13+ 获取图片权限

  • READ_EXTERNAL_STORAGE:Android 10 及以下读取图片


2. FileProvider 注册

<application> 中添加 FileProvider:

XML 复制代码
<!-- @string/file_provider 对应 Java 中调用 -->
 <!-- 配置provider -->
        <!-- 兼容Android7.0,把访问文件的Uri方式改为FileProvider -->
        <!-- android:grantUriPermissions 必须设型为true -->
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="@string/file_provider" 
    android:grantUriPermissions="true">
 <!-- 配置哪些路径是可以通过FileProvider访问的 -->
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>
详细解释:
属性 作用
android:name 指定 FileProvider 的类名,这里使用官方提供的 androidx.core.content.FileProvider,无需自定义
android:authorities 唯一标识 FileProvider ,用于 Java 代码中调用 getUriForFile() 时提供。建议放在 strings.xml 中统一管理
android:grantUriPermissions 必须设置为 true,允许临时授权目标 App 访问共享文件
<meta-data> 声明文件路径的配置文件在哪,用来告诉 FileProvider 哪些文件可以被共享(通常是 /res/xml/file_paths.xml

📌 提醒:

  • authorities 应确保唯一性 ,通常使用包名作为前缀,如:com.example.myapp.fileProvider

  • 若多个 app 有同名 authorities 会冲突,导致运行时崩溃


4. 配置 strings.xml
XML 复制代码
<resources>
    <string name="app_name">chapter04-client</string>
    <string name="file_provider">com.example.chapter04_client.fileProvider</string>
</resources>

只是一个字符串资源,它的作用是:

告诉系统:我这个 FileProvider 的唯一标识符是 com.example.chapter04_client.fileProvider,用这个去注册、访问、匹配。


5. 配置 file_paths.xml

创建文件:res/xml/file_paths.xml,内容如下:

XML 复制代码
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!--    SD 卡的download下面的文件
        name可以随便取-->
    <external-path
        name="external_storage_download"
        path="Download"/>

<!--    sdcard下所有的文件都可以访问-->
<!--    <external-path-->
<!--        name="external_storage_root"-->
<!--        path="."/>-->
</paths>
标签 含义
<paths> FileProvider 的路径声明根标签,必须包含 xmlns:android 命名空间
<external-path> 映射的是外部存储根目录(如 /storage/emulated/0/
name 属性 给这个路径取个别名,Java 代码不使用,仅用于记录
path="." 表示共享整个外部路径的所有子目录,比如 Download/, DCIM/, Pictures/

三、使用 MediaStore 加载图片的关键

1. MediaStore 是什么?

MediaStore 是 Android 系统提供的一个 内容提供器(ContentProvider),用于访问设备上的多媒体文件(如图片、视频、音频等)的元数据。

它的作用类似于一个"媒体数据库",你可以像操作数据库一样通过查询语句获取文件信息。

2. 为什么需要手动扫描文件夹?
  • Android 系统有一个"媒体扫描器"用于定期开机时 扫描外部存储,把图片、音频、视频等注册进系统的媒体数据库 MediaStore

  • 但它不会实时监控所有文件变化

  • 如果你在程序中保存了新图片(如下载了一张图片到 /Download),但没有通知系统扫描:

    • 图库里看不到

    • MediaStore.Images.Media 查询不到

    • 发送彩信或上传时也无法选择该图

java 复制代码
   // 手动让MediaStore扫描入库
   MediaScannerConnection.scanFile(this,
    new String[]{Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toString()},null,null);

等同于说:

"嘿 Android 系统,请立刻重新扫描这个目录,把其中的新文件加入到媒体库里!"

手动调用 MediaScannerConnection.scanFile() 是为了让系统立刻知道你新保存的文件存在,并能马上通过图库或 MediaStore 正常访问到它,是"同步媒体数据库"的关键一步。

3. 查询图片的常用 URI 与字段
bash 复制代码
MediaStore.Images.Media.EXTERNAL_CONTENT_URI

表示外部存储上的图片(例如 /sdcard/DCIM//sdcard/Download/

常用字段:

字段常量 描述
MediaStore.Images.Media._ID 图片的唯一编号
MediaStore.Images.Media.TITLE 图片标题(文件名,不包含后缀)
MediaStore.Images.Media.SIZE 图片大小(单位:字节)
MediaStore.Images.Media.DATA 图片的完整路径(注意:Android Q 起已废弃!

⚠️ Android Q(10)及以后建议用 ContentResolver.openInputStream(uri) 获取实际图片内容,而不是使用 DATA 路径。

4. 从系统媒体库中加载符合条件的图片列表,并保存在 mImageList 中备用代码
java 复制代码
    @SuppressLint("Range")
    private void loadImageList() {
        // MediaStore 可能会访问到非可访问目录下的文件
        // 指定要查询的列字段,包括图片编号、标题、大小、路径。
        String[] columns = new String[]{
                MediaStore.Images.Media._ID,// 编号
                MediaStore.Images.Media.TITLE,// 标题
                MediaStore.Images.Media.SIZE,// 文件大小
                MediaStore.Images.Media.DATA// 文件路径

        };
        // 图片大小在1M以内
        Cursor cursor = getContentResolver().query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                columns,
                "_size<1048576",
                null,
                "_size DESC" //结果按照大小从大到小排序
        );

        int count = 0;
        if(cursor != null){
            // 遍历结果 Cursor,最多获取 6 张图片
            while(cursor.moveToNext() && count < 6){
                ImageInfo image = new ImageInfo();
                image.id =  cursor.getLong(cursor.getColumnIndex(MediaStore.Images.Media._ID));
                image.name =  cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.TITLE));
                image.size = cursor.getLong(cursor.getColumnIndex(MediaStore.Images.Media.SIZE));
                image.path =  cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
                // 如果符合FileProvider则加入集合
                if(FileUtil.checkFileUri(this,image.path)){
                    count++;
                    mImageList.add(image);
                }
                Log.d("ning","image:"+image.toString());
            }
        }
    }
  • 每次取出一张图片的信息,封装成 ImageInfo 对象。

  • 调用 FileUtil.checkFileUri(...) 判断图片是否位于 FileProvider 可共享路径中(例如 /storage/emulated/0/Download/)。

  • 只加入符合条件的前 6 张图片到 mImageList

5.FileUtil中的checkFileUri

验证某个文件路径是否有效,并能被当前 App 的 FileProvider 成功生成 content:// URI,用于安全共享给其他 App(如短信/彩信 App)

java 复制代码
   //  判断一个图片文件路径是否是合法的,并且是否能够被 FileProvider 成功共享
    public static boolean checkFileUri(Context ctx,String path){
        File file = new File(path);
        Log.d("ning","old path:"+path);
        if(!file.exists() || !file.isFile() || file.length() <= 0) return false;
        try {
            //检测文件路径是否支持 FileProvider 访问方式,如果发生异常,说明不支持
            if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
                FileProvider.getUriForFile(ctx, ctx.getString(R.string.file_provider), file);
            }
        }catch (Exception e){
            e.printStackTrace();
            return false;
        }
        return true;
    }
  • 尝试通过 FileProvider 为这个文件生成 content:// 类型的 URI

  • 如果生成失败(比如路径不在 file_paths.xml 声明的范围内),getUriForFile() 会抛异常 → 返回 false

  • 否则说明这个路径合法、受支持 → 继续执行

四、使用FileProvider发送带图片的彩信

构建一个带文字内容和图片附件的彩信(MMS)发送 Intent,通过 FileProvider 共享图片文件,兼容 Android 7.0+。

java 复制代码
private void sendMms(String phone, String name, String message, String path)
参数 含义
phone 收件人手机号
name 彩信标题(subject)
message 彩信正文内容
path 图片文件的本地路径
① 创建 URI
java 复制代码
Uri uri = Uri.parse(path);

初步将文件路径转换成 URI,但注意这只是 file:// 形式的 URI(不安全,不能跨进程共享)

② 使用 FileProvider 转换为 content:// URI
java 复制代码
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    uri = FileProvider.getUriForFile(
        this,
        getString(R.string.file_provider), // 例如 "com.example.chapter04_client.fileProvider"
        new File(path)
    );
    Log.d("ning", "new uri:" + uri.toString());
}

Android 7.0(API 24)及以后系统禁止使用 file:// URI 跨进程传递文件

FileProvider 是官方推荐的解决方案,它会把 file:// 变为 content:// 并且做权限封装

③ 构建 Intent
java 复制代码
Intent intent = new Intent(Intent.ACTION_SEND);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
意图类型 说明
ACTION_SEND 启动系统发送界面(可选择短信/彩信 App)
FLAG_ACTIVITY_NEW_TASK 如果从非 Activity 中启动需要此 flag
FLAG_GRANT_READ_URI_PERMISSION 授权对方 App 临时读取图片(content://)
④ 添加彩信内容
java 复制代码
intent.putExtra("address", phone);         // 收件人号码
intent.putExtra("subject", name);          // 彩信标题
intent.putExtra("sms_body", message);      // 彩信正文内容
intent.putExtra(Intent.EXTRA_STREAM, uri); // 彩信附件:图片
intent.setType("image/*");                 // 附件类型为图片

❗ 注意:虽然 "address""sms_body" 并不是标准 EXTRA,但很多系统短信 App 能识别这些字段。

⑤ 启动 Intent
java 复制代码
startActivity(intent);
Toast.makeText(this, "请在弹窗中选择短信或者信息应用", Toast.LENGTH_SHORT);
  • 启动系统的发送界面(如"信息"App)

  • 弹出 Toast 提示用户选择发送应用

示例流程
bash 复制代码
Activity onCreate()
   ↓
手动扫描 /Download 目录
   ↓
检查 READ_MEDIA_IMAGES 权限
   ↓
如果已授权 → 加载图片列表
                    ↓
          查询 MediaStore 图片(<1MB)
                    ↓
        检查每张图是否支持 FileProvider
                    ↓
          添加到 mImageList 并显示网格图
                    ↓
            用户点击某张图片
                    ↓
      → 调用 sendMms() 构建 Intent
                    ↓
    → 使用 FileProvider 转换 content://
                    ↓
       → 调用系统彩信应用发送图片
java 复制代码
public class ProviderMmsActivity extends AppCompatActivity {

    private List<ImageInfo> mImageList = new ArrayList<>();
    private GridLayout gl_appendix;

    // 读写权限
    private static final String[] PERMISSIONS = new String[]{
            Manifest.permission.READ_MEDIA_IMAGES
    };

    //	区分不同权限请求(例如拍照权限、存储权限、定位权限)
    private static final int PERMISSION_REQUEST_CODE=1;
    private TextView et_phone;
    private TextView et_name;
    private TextView et_content;

    // 弹框的选择
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        // 权限被允许
        if(requestCode == PERMISSION_REQUEST_CODE &&
                PermissionUtil.checkGrant(grantResults)){
            // 加载图片列表
            loadImageList();
            // 将图片显示在图像网格当中
            showImageGrid();
        }
    }


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        EdgeToEdge.enable(this);
        setContentView(R.layout.activity_provider_mms);
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });

        gl_appendix = findViewById(R.id.gl_appendix);
        et_phone = findViewById(R.id.et_phone);
        et_name = findViewById(R.id.et_name);
        et_content = findViewById(R.id.et_content);
        // 手动让MediaStore扫描入库
        MediaScannerConnection.scanFile(this,
                new String[]{Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toString()},
                null,null);

        if (PermissionUtil.checkPermission(this,PERMISSIONS,PERMISSION_REQUEST_CODE)) {
            // 加载图片列表
            loadImageList();
            // 将图片显示在图像网格当中
            showImageGrid();
        }

    }

    private void showImageGrid() {
        gl_appendix.removeAllViews();
        for (ImageInfo image : mImageList) {
            // image->ImageView然后再添加到GridLayout当中
            ImageView iv_appendix = new ImageView(this);
            Bitmap bitmap = BitmapFactory.decodeFile(image.path);
            iv_appendix.setImageBitmap(bitmap);
            // 设置图像缩放类型
            iv_appendix.setScaleType(ImageView.ScaleType.FIT_CENTER);
            // 设置图像视图的布局参数
            int px = Utils.dip2px(this,110);
            ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(px,px);
            iv_appendix.setLayoutParams(params);
            // 内部间距
            int padding = Utils.dip2px(this,5);
            iv_appendix.setPadding(padding,padding,padding,padding);
            iv_appendix.setOnClickListener(v->{
                sendMms(et_phone.getText().toString(),
                        et_name.getText().toString(),
                        et_content.getText().toString(),
                        image.path);
            });
            gl_appendix.addView(iv_appendix);
        }
    }

    // 显示6张
    @SuppressLint("Range")
    private void loadImageList() {}
    // 发送带图片的彩信
    private void sendMms(String phone, String name,String message,String path) {
    }
}

五、借助FileProvider安装应用

一、为什么要使用 FileProvider 安装 APK?

❗ 背景问题

Android 7.0(API 24)开始 ,系统禁止通过 file:// URI 在 App 间传递文件,尤其是用于 APK 安装。

如果你用传统方式:

java 复制代码
Uri uri = Uri.parse("file:///sdcard/xxx.apk");

会导致报错:

FileUriExposedException: file:// URI exposure is not allowed

✅ 解决方案:使用 FileProvider

将本地文件路径转换为安全的 content:// URI,并授权访问,避免暴露 file 路径。

二、流程概览

bash 复制代码
用户点击"安装"按钮
    ↓
Android 11+ 检查 MANAGE_EXTERNAL_STORAGE 权限
    ↓
如果未授权 → 跳转设置
如果已授权或版本较低 → 检查动态权限
    ↓
构造 APK 文件路径(Download/chapter-01-release.apk)
    ↓
验证 APK 文件是否有效(PackageInfo != null)
    ↓
通过 FileProvider 获取 content:// URI
    ↓
构造 ACTION_VIEW + GRANT_READ_URI_PERMISSION 的 Intent
    ↓
启动系统安装器安装 APK

三、关键步骤与知识点拆解

1️⃣ 权限处理(分版本)
Android 11(API 30)及以上:

必须获取"管理所有文件 "权限:MANAGE_EXTERNAL_STORAGE

Manifest 权限声明

XML 复制代码
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />

java动态管理

java 复制代码
 @RequiresApi(api = Build.VERSION_CODES.R)
    private void checkAndInstall() {
        // 检查是否拥有MANAGE EXTERNAL STORAGE 权限,没有则跳转到设置页面
        if(!Environment.isExternalStorageManager()){
            // 设置页面的构建
            Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            intent.setData(Uri.fromParts("package",getPackageName(),null));
            startActivity(intent);
        }else installApk();
    }

该方法只能在 Android 11(API 30 / R)及以上系统上运行, 判断是否拥有"管理所有文件权限"

如果没有权限 → 跳转到系统设置页面:

Android 10 及以下:

动态申请 READ_MEDIA_IMAGESREAD_EXTERNAL_STORAGE 即可访问公有目录

Manifest 权限声明

XML 复制代码
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

java动态管理

java 复制代码
    // 读写权限
    private static final String[] PERMISSIONS = new String[]{
            Manifest.permission.READ_MEDIA_IMAGES
    };

    //	区分不同权限请求(例如拍照权限、存储权限、定位权限)
    private static final int PERMISSION_REQUEST_CODE=1;

    if (PermissionUtil.checkPermission(this, PERMISSIONS, REQUEST_CODE)) {
        installApk();
   }
    // 判断用户选择是否同意权限的回调函数
   @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if(requestCode == PERMISSION_REQUEST_CODE &&
                PermissionUtil.checkGrant(grantResults)){
            installApk();
        }
    }

2️⃣ APK 路径的构造
java 复制代码
String apkPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/chapter-01-release.apk";
  • 表示 APK 文件保存在系统的 /Download/ 公共目录中

  • 可通过文件管理器等方式手动放入


3️⃣ 校验 APK 文件是否有效
java 复制代码
 //获取应用包管理器
PackageManager pm = getPackageManager();
// 获取apk文件的包信息
PackageInfo pi = pm.getPackageArchiveInfo(apkPath, PackageManager.GET_ACTIVITIES);
if(pi == null){
      // 文件损坏
      Toast.makeText(this,"安装文件已经损坏了",Toast.LENGTH_SHORT).show();
      return;
}
  • 尝试解析该 APK 文件的包信息

  • 如果返回 null,表示文件可能已损坏


4️⃣ 获取 FileProvider 的 content:// URI
java 复制代码
// 交给系统启用安装
        Uri uri = Uri.parse(apkPath);
        // 兼容Android7.0,把访问文件的Uri方式改为FileProvider
        if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
            // 通过FileProvider获得文件的Uri访问方式
            uri = FileProvider.getUriForFile(this, getString(R.string.file_provider), new File(apkPath));
            Log.d("ning","new uri:"+uri.toString());
        }
  • getUriForFile(...) 会根据你配置的 file_paths.xml 返回安全的 content:// URI

  • 例如:content://com.example.chapter04_client.fileProvider/external_files/Download/chapter-01-release.apk

✅ 记得配置 AndroidManifest.xml 中的 FileProvider 和路径规则!


5️⃣ 构建安装 Intent 并启动安装器
java 复制代码
// 创建一个用于查看(打开)指定内容的 Intent(适用于安装器)
Intent intent = new Intent(Intent.ACTION_VIEW);

// 设置数据和 MIME 类型
// uri 是 APK 文件的 content:// 形式 URI(通过 FileProvider 获取)
// "application/vnd.android.package-archive" 是 APK 的 MIME 类型
intent.setDataAndType(uri, "application/vnd.android.package-archive");

// 授权目标应用(安装器)临时读取该 URI,否则安装器将无法访问 APK 文件内容
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

// 启动新任务栈,避免非 Activity Context 启动崩溃(如从 Service 调用)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

// 启动系统安装器应用,开始安装流程
startActivity(intent);
  • ACTION_VIEW + application/vnd.android.package-archive 表示打开安装器安装 APK

  • 必须加 FLAG_GRANT_READ_URI_PERMISSION:授权目标 App(安装器)读取 URI

FileProvider总结

一、什么是 FileProvider?

FileProvider 是 Android 提供的一个 ContentProvider 子类,用于安全地在 App 之间共享文件。

Android 7.0(API 24)开始 ,系统禁止直接使用 file:// URI 在应用间传递文件,必须使用 content:// URI,而 FileProvider 正是官方推荐的解决方案


二、FileProvider 解决了什么问题?

问题 解决方式
Android 7.0+ 禁止 file:// 跨应用传递 FileProvider 使用 content:// URI 安全封装文件路径
需要授权目标应用访问共享文件 自动配合 FLAG_GRANT_READ_URI_PERMISSION 使用
URI 权限限制、访问受控 只开放配置在 file_paths.xml 中的目录

三、FileProvider如何使用?

java 复制代码
uri = FileProvider.getUriForFile(
    this,                              // 当前 Context
    getString(R.string.file_provider), // 在 strings.xml 配置的 authority,如 com.example.app.fileProvider
    new File(apkPath)                  // 要共享的本地文件(APK)
);

👉 把 File(本地路径)转换成内容 URI(content:// 格式)

  • Android 7.0(API 24)开始,不能直接把 file:// 的路径分享给其他 App (会抛出 FileUriExposedException

  • 所以你必须用 FileProvider 把 file 转成 content Uri

  • 然后再加上 Intent.FLAG_GRANT_READ_URI_PERMISSION 权限,其他 App 才能读取这个文件

相关推荐
聪明努力的积极向上3 小时前
【MYSQL】IN查询优化
数据库·mysql
济南java开发,求内推3 小时前
MongoDB: 升级版本至:5.0.28
数据库·mongodb
小灰灰搞电子3 小时前
Qt PDF模块详解
数据库·qt·pdf
老华带你飞3 小时前
健身房预约|基于springboot 健身房预约小程序系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·小程序
梁萌3 小时前
MySQL主从数据同步实战
数据库·mysql
嘻哈baby3 小时前
MySQL主从复制与读写分离实战指南
数据库·mysql·adb
一水鉴天3 小时前
整体设计 定稿 之 5 讨论问题汇总 和新建 表述总表/项目结构表 文档分析,到读表工具核心设计讨论(豆包助手)
数据库·人工智能·重构
大大大大物~3 小时前
JVM 之 垃圾回收算法及其内部实现原理【垃圾回收的核心问题有哪些?分别怎么解决的?可达性分析解决了什么问题?回收算法有哪些?内部怎么实现的?】
jvm·算法
冰冰菜的扣jio3 小时前
JVM中的垃圾回收详解
java·jvm