Skip to content
<

内容共享

本章介绍 Android 不同应用之间共享内容的具体方式,主要包括:如何利用内容组件在应用之间共享数据,如何使用内容组件获取系统的通讯信息,如何借助文件提供器在应用之间共享文件等。

在应用之间共享数据

本节介绍 Android 4 大组件之一 ContentProvider 的基本概念和常见用法。首先说明如何使用内容提供器封装内部数据的外部访问接口,接着阐述如何使用内容解析器通过外部接口操作内部数据。

通过 ContentProvider 封装数据

Android 号称提供了 4 大组件,分别是活动 Activity、广播 Broadcast、服务 Service 和内容提供器 ContentProvider。其中内容提供器涵盖与内部数据存取有关的一系列组件,完整的内容组件由内容提供器 ContentProvider、内容解析器 ContentResolver、内容观察器 ContentObserver 三部分组成。

ContentProvider 给 App 存取内部数据提供了统一的外部接口,让不同的应用之间得以互相共享数据。像上一章提到的 SQLite 可操作应用自身的内部数据库;上传和下载功能可操作后端服务器的文件;而 ContentProvider 可操作当前设备其他应用的内部数据,它是一种中间层次的数据存储方式。

在实际编码中,ContentProvider 只是服务端 App 存取数据的抽象类,开发者需要在其基础上实现一个完整的内容提供器,并重写下列数据库管理方法。

  • onCreate:创建数据库并获得数据库连接。
  • insert:插入数据。
  • delete:删除数据。
  • update:更新数据。
  • query:查询数据,并返回结果集的游标。
  • getType:获取内容提供器支持的数据类型。

这些方法看起来是不是很像 SQLite?没错,ContentProvider 作为中间接口,本身并不直接保存数据,而是通过 SQLiteOpenHelper 与 SQLiteDatabase 间接操作底层的数据库。所以要想使用 ContentProvider,首先得实现 SQLite 的数据库帮助器,然后由 ContentProvider 封装对外的接口。以封装用户信息为例,具体步骤主要分成以下 3 步。

编写用户信息表的数据库帮助器

这个数据库帮助器就是常规的 SQLite 操作代码,实现过程参见上一章的“数据库帮助器 SQLiteOpenHelper”,完整代码参见 chapter07\src\main\java\com\example\chapter07\database\UserDBHelper.java。

编写内容提供器的基础字段类

该类需要实现接口 BaseColumns,同时加入几个常量定义。详细代码示例如下:

(完整代码见 chapter07_server\src\main\java\com\example\chapter07_server\provider\UserInfoContent.java)

java
public class UserInfoContent implements BaseColumns {

    public static final String AUTHORITIES = "com.dongnaoedu.chapter07_server.provider.UserInfoProvider";

    //content://com.dongnaoedu.chapter07_server.provider.UserInfoProvider/user

    // 访问内容提供器的URI
    public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITIES + "/user");

    // 下面是该表的各个字段名称
    public static final String USER_NAME = "name";
    public static final String USER_AGE = "age";
    public static final String USER_HEIGHT = "height";
    public static final String USER_WEIGHT = "weight";
    public static final String USER_MARRIED = "married";

}

通过右键菜单创建内容提供器

右击 App 模块的包名目录,在弹出的右键菜单中依次选择 New->Other->Content Provider,打开如图 7-1 所示的组件创建对话框。

在创建对话框的 Class Name 一栏填入内容提供其的名称,比如 UserInfoProvider;在 URI Authorities 一栏填写 URI 的授权串,比如“com.example.chapter07_server.provider.UserInfoProvider”;然后单击对话框右下角的 Finish 按钮,完成提供器的创建操作。

上述创建过程会自动修改 App 模块的两处地方,一处是往 AndroidManifest.xml 添加内容提供器的注册配置,配置信息示例如下:

xml
<provider
    android:name=".provider.UserInfoProvider"
    android:authorities="com.dongnaoedu.chapter07_server.provider.UserInfoProvider"
    android:enabled="true"
    android:exported="true" />

另一处是在包名目录下生成名为 UserInfoProvider.java 的代码文件,打开一看发现该类继承了 ContentProvider,并且提示重写 onCreate、insert、delete、query、update、getType 等方法,为此重写 onCreate 方法,在此获取用户信息表的数据库帮助器实例,其他 insert、delete、query 等方法也要加入对应的数据库操作代码,修改之后的内容提供器代码如下所示:

(完整代码见 chapter07_server\src\main\java\com\example\chapter07_server\provider\UserInfoProvider.java)

java
public class UserInfoProvider extends ContentProvider {

    private UserDBHelper dbHelper;
    private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);

    private static final int USERS = 1;
    private static final int USER = 2;

    static {
        // 往Uri匹配器中添加指定的数据路径
        URI_MATCHER.addURI(UserInfoContent.AUTHORITIES, "/user", USERS);
        URI_MATCHER.addURI(UserInfoContent.AUTHORITIES, "/user/#", USER);
    }

    @Override
    public boolean onCreate() {
        Log.d("ning", "UserInfoProvider onCreate");
        dbHelper = UserDBHelper.getInstance(getContext());
        return true;
    }

    // content://com.dongnaoedu.chapter07_server.provider.UserInfoProvider/user
    @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 newUri = ContentUris.withAppendedId(UserInfoContent.CONTENT_URI, rowId);
                // 通知监听器,数据已经改变
                getContext().getContentResolver().notifyChange(newUri, null);
            }
        }
        return uri;
    }

    @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;
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        int count = 0;
        switch (URI_MATCHER.match(uri)) {
            //content://com.dongnaoedu.chapter07_server.provider.UserInfoProvider/user
            // 删除多行
            case USERS:
                SQLiteDatabase db1 = dbHelper.getWritableDatabase();
                count = db1.delete(UserDBHelper.TABLE_NAME, selection, selectionArgs);
                db1.close();
                break;

            //content://com.dongnaoedu.chapter07_server.provider.UserInfoProvider/user/2
            //删除单行
            case USER:
                String id = uri.getLastPathSegment();
                SQLiteDatabase db2 = dbHelper.getWritableDatabase();
                count = db2.delete(UserDBHelper.TABLE_NAME, "_id=?", new String[]{id});
                db2.close();
                break;
        }

        return count;
    }

    @Override
    public String getType(Uri uri) {
        // TODO: Implement this to handle requests for the MIME type of the data
        // at the given URI.
        throw new UnsupportedOperationException("Not yet implemented");
    }


    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {
        throw new UnsupportedOperationException("Not yet implemented");
    }
}

经过以上三个步骤之后,便完成了服务端 App 的接口封装工作,接下来再由其他 App 去访问服务端 App 的数据。

通过 ContentResolver 访问数据

上一小节提到了利用 ContentProvider封装服务端 App 的数据,如果客户端 App 想访问对方的内部数据,就要借助内容解析器 ContentResolver。内容解析器是客户端操作服务端数据的工具,与之对应的内容提供器则是服务端的数据接口。在活动代码中调用 getContentResolver 方法,即可获取内容解析器的实例。

ContentResolver 提供的方法与 ContentProvider 一一对应,比如 insert、delete、query、update、getType 等,甚至连方法的参数类型都雷同。以添加操作为例,针对前面 UserInfoProvider 提供的数据接口,下面由内容解析器调用 insert 方法,使之往内容提供器插入一条用户信息,记录添加代码如下所示:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\ContentWriteActivity.java)

java
// 添加一条用户记录
private void addUser() {
    ContentValues values = new ContentValues();
    values.put(UserInfoContent.USER_NAME, et_name.getText().toString());
    values.put(UserInfoContent.USER_AGE, Integer.parseInt(et_age.getText().toString()));
    values.put(UserInfoContent.USER_HEIGHT, Integer.parseInt(et_height.getText().toString()));
    values.put(UserInfoContent.USER_WEIGHT, Float.parseFloat(et_weight.getText().toString()));
    values.put(UserInfoContent.USER_MARRIED, ck_married.isChecked());
    // content://com.dongnaoedu.chapter07_server.provider.UserInfoProvider/user
    getContentResolver().insert(UserInfoContent.CONTENT_URI, values);
}

至于删除操作就更简单了,只要下面一行代码就删除了所有记录:

java
getContentResolver().delete(UserInfoContent.CONTENT_URI, "1=1", null);

查询操作稍微复杂一些,调用 query 方法会返回游标对象,这个游标正是 SQLite 的游标 Cursor,详细用法参见上一章的“6.2.3 数据库帮助器 SQLiteOpenHelper”。query 方法的输入参数有好几个,具体说明如下(依参数顺序排列)。

  • uri:Uri 类型,指定本次操作的数据表路径。
  • projection:字符串数组类型,指定将要查询的字段名称列表。
  • selection:字符串类型,指定查询条件。
  • selectionArgs:字符串数组类型,指定查询条件中的参数取值列表。
  • sortOrder:字符串类型,指定排序条件。

下面是调用 query 方法从内容提供器查询所有用户信息的代码例子:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\ContentReadActivity.java)

java
List<User> users = new ArrayList<>();
Cursor cursor = getContentResolver().query(UserInfoContent.CONTENT_URI, null, null, null, null);
if (cursor != null) {
    while (cursor.moveToNext()) {
        User info = new User();
        info.id = cursor.getInt(cursor.getColumnIndex(UserInfoContent._ID));
        info.name = cursor.getString(cursor.getColumnIndex(UserInfoContent.USER_NAME));
        info.age = cursor.getInt(cursor.getColumnIndex(UserInfoContent.USER_AGE));
        info.height = cursor.getInt(cursor.getColumnIndex(UserInfoContent.USER_HEIGHT));
        info.weight = cursor.getFloat(cursor.getColumnIndex(UserInfoContent.USER_WEIGHT));
        info.married = cursor.getInt(cursor.getColumnIndex(UserInfoContent.USER_MARRIED)) == 1 ? true : false;
        Log.d("ning", info.toString());
        users.add(info);
    }
    cursor.close();
}
String contactCount = String.format("当前共找到%d个用户", users.size());
tv_desc.setText(contactCount);
ll_list.removeAllViews(); // 移除线性布局下面的所有下级视图
for (User user : users) {
    String contactDesc = MessageFormat.format("姓名为{0},年龄为{1},身高为{2},体重为{3}\n",
            user.name, user.age, user.height, user.weight);
    TextView tv_contact = new TextView(this); // 创建一个文本视图
    tv_contact.setText(contactDesc);
    tv_contact.setTextColor(Color.BLACK);
    tv_contact.setTextSize(17);
    int pad = Utils.dip2px(this, 5);
    tv_contact.setPadding(pad, pad, pad, pad); // 设置文本视图的内部间距
    ll_list.addView(tv_contact); // 把文本视图添加至线性布局
}

接下来分别演示通过内容解析器添加和查询用户信息的过程,其中记录添加页面为 ContentWriteActivity.java,记录查询页面为 ContentReadActivity.java。运行测试 App,先打开记录添加页面,输入用户信息后点击添加按钮,由内容解析器执行插入操作,此时添加界面如图 7-2 所示。接着打开记录查询页面,内容解析器自动执行查询操作,并将查到的用户信息一一显示出来,此时查询界面如图 7-3 所示。

对比添加页面和查询页面的用户信息,可知成功查到了新增的用户记录。

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

本节介绍了使用内容组件获取通讯信息的操作办法,包括:如何在 App 运行的时候动态申请权限(访问通讯信息要求获得相应授权),如何利用内容解析器读写联系人信息,如何利用内容观察器监听收到的短信内容等。

运行时动态申请权限

上一章的“公共存储空间与私有存储空间”提到,App 若想访问存储卡的公共空间,就要在 AndroidManifest.xml 里面添加下述的权限配置。

xml
<!-- 存储卡读写 -->
<uses-permission adnroid:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

然而即使 App 声明了完整的存储卡操作权限,从 Android 7.0 开始,系统仍然默认禁止该 App 访问公共空间,必须到设置界面手动开启应用的存储卡权限才行。尽管此举是为用户隐私着想,可是人家咋知道要手工开权限呢?就算用户知道,去设置界面找到权限开关也颇费周折。为此 Android 支持在 Java 代码中处理权限,处理过程分为 3 个步骤,详述如下:

  1. 检查 App 是否开启了指定权限

权限检查需要调用 ContextCompat 的 checkSelfPermission 方法,该方法的第一个参数为活动实例,第二个参数为待检查的权限名称,例如存储卡的写权限名为 Manifest.permission.WRITE_EXTERNAL_STORAGE。注意 checkSelfPermission 方法的返回值,当它为 PackageManager.PERMISSION_GRANTED 时表示已经授权,否则就是未获授权。

  1. 请求系统弹窗,以便用户选择是否开启权限

一旦发现某个权限尚未开启,就得弹窗提示用户手工开启,这个弹窗不是开发者自己写的提醒对话框,而是系统专门用户权限申请的对话框。调用 ActivityCompat 的 requestPermissions 方法,即可命令系统自动弹出权限申请窗口,该方法的第一个参数为活动实例,第二个参数为待申请的权限名称数组,第三个参数为本次操作的请求代码。

  1. 判断用户的权限选择结果

然而上面第二步的 requestPermissions 方法没有返回值,那怎么判断用户到底选了开启权限还是拒绝权限呢?其实活动页面提供了权限选择的回调方法 onRequestPermissionResult,如果当前页面请求弹出权限申请窗口,那么该页面的 Java 代码必须重写 onRequestPermissionResult 方法,并在该方法内部处理用户的权限选择结果。

具体到编码实现上,前两步的权限校验和请求弹窗可以合并到一块,先调用 checkSelfPermission 方法检查某个权限是否已经开启,如果没有开启再调用 requestPermissions 方法请求系统弹窗。合并之后的检查方法示例如下,此处代码支持一次检查一个权限,也支持一次检查多个权限:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\util\PermissionUtil.java)

java
// 检查某个权限。返回 true 表示已启用该权限,返回 false 表示未启用该权限
public static boolean checkPermission(Activity act, String permission, int requestCode) {
    return checkPermission(act, new String[]{permission}, requestCode);
}

// 检查多个权限。返回 true 表示已完全启用权限,返回 false 表示未完全启用权限
public static boolean checkPermission(Activity act, String[] permissions, int requestCode) {
    // Android 6.0 之后开始采用动态权限管理
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        int check = PackageManager.PERMISSION_GRANTED;
        for (String permission : permissions) {
            check = ContextCompat.checkSelfPermission(act, permission);
            if (check != PackageManager.PERMISSION_GRANTED) {
                break;
            }
        }
        // 未开启该权限,则请求系统弹窗,好让用户选择是否立即开启权限
        if (check != PackageManager.PERMISSION_GRANTED) {
            ActivityCompat.requestPermissions(act, permissions, requestCode);
            return false;
        }
    }
    return true;
}

注意到上面代码有判断安卓版本号,只有系统版本大于 Android 6.0(版本代号为 M),才执行后续的权限校验操作。这是因为从 Android 6.0 开始引入了运行时权限机制,在 Android 6.0 之前,只要 App 在 AndroidManifest.xml 中添加了权限配置,则系统会自动给 App 开启相关权限;但在 Android 6.0 之后,即便事先添加了权限配置,系统也不会自动开启权限,而要开发者在 App 运行时判断权限的开关情况,再据此动态申请未获授权的权限。

回到活动页面代码,一方面增加权限校验入口,比如点击某个按钮后出发权限检查操作,其中 Manifest.permission.WRITE_EXTERNAL_STORAGE 表示存储卡权限,入口代码如下:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\MainActivity.java)

java
if (v.getId() == R.id.btn_file_write) {
    if (PermissionUtil.checkPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE, R.id.btn_file_write % 65536)) {
        startActivity(new Intent(this, FileWriteActivity.class));
    }
}

另一方面还要重写活动的 onRequestPermissionsResult 方法,在方法内部校验用户的选择结果,若用户同意授权,就执行后续业务;若用户拒绝授权,只能提示用户无法开展后续业务了。重写后的方法代码如下所示:

java
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        // requestCode 不能为负数,也不能大于 2 的 16 次方即 65536
        if (requestCode == R.id.btn_file_write % 65536) {
            if (PermissionUtil.checkGrant(grantResults)) {
                startActivity(new Intent(this, FileWriteActivity.class));
            } else {
                ToastUtil.show(this, "需要允许存储卡权限才能写入公共空间噢");
            }
        }
    }

以上代码为了简化逻辑,将结果校验操作封装为 PermissionUtil 的 checkGrant 方法,该方法遍历授权结果数组,一次检查每个权限是否都得到授权了。详细的方法代码如下所示:

java
// 检查权限结果,返回 true 表示都已经获得授权。返回 false 表示至少有一个未获得授权
public static boolean checkGrant(int[] grantResults) {
    boolean result = true;
    if (grantResults != null) {
        for (int grant : grantResults) { // 遍历权限结果数组中的每条选择结果
            if (grant != PackageManager.PERMISSION_GRANTED) { // 未获得授权
                result = false;
                break;
            }
        }
    } else {
        result = false;
    }
    return result;
}

代码都改好后,运行测试 App,由于一开始 App 默认未开启存储卡权限,因此点击按钮 btn_file_write 触发了权限校验操作,弹出如图 7-4 所示的存储卡权限申请窗口。

点击弹窗上的“始终允许”,表示同意赋予存储卡读写权限,然后系统自动给 App 开启了存储卡权限,并执行后续处理逻辑,也就是跳到了 FileWriteActivity 页面,在该页面即可访问公共空间的文件了。但在 Android 10 系统中,即使授权通过,App 仍然无法访问公共空间。,这是因为 Android 10 默认开启沙箱模式,不允许直接使用公共空间的文件路径,此时要修改 AndroidManifest.xml,给 application 节点添加如下的 requestLegacyExternalStorage 属性:

xml
android:requestLegacyExternalStorage="true"

从 Android 11 开始,为了让应用升级时也能正常访问公共空间,还得修改 AndroidManifest.xml,给 application 节点添加如下的 preserveLegacyExternalStorage 属性,表示暂时关闭沙箱模式:

xml
android:perserveLegacyExternalStorage="true"

出了存储卡的读写权限,还有部分权限也要求运行时动态申请,这些权限名称的取值说明见表 7-1。

代码中的权限名称权限说明
Manifest.permission.READ_EXTERNAL_STORAGE读存储卡
Manifest.permission.WRITE_EXTERNAL_STORAGE写存储卡
Manifest.permission.READ_CONTACTS读联系人
Manifest.permission.WRITE_CONTACTS写联系人
Manifest.permission.SEND_SMS发送短信
Manifest.permission.REVEIVE_SMS接收短信
Manifest.permission.READ_SMS读短信
Manifest.permission.READ_CALL_LOG读通话记录
Manifest.permission.WRITE_CALL_LOG写通话记录
Manifest.permission.CAMERA相机
Manifest.permission.RECORD_AUDIO录音
Manifest.permission.ACCESS_FINE_LOCATION精确定位

利用 ContentResolver 读写联系人

在实际开发中,普通 App 很少会开放数据接口给其他应用访问,作为服务端接口的 ContentProvider 基本用不到。内容组件能够派上用场的情况,往往是 App 想要访问系统应用的通讯数据,比如查看联系人、短信、通话记录,以及对这些通讯数据进行增、删、改、查。

访问系统的通讯数据之前,得先在 AndroidManifest.xml 添加相应的权限配置,常见的通讯权限配置主要有下面几个:

xml
<!-- 联系人/通讯录。包括读联系人、写联系人 -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<!-- 短信。包括发送短信、接收短信、读短信 -->
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
<!-- 通话记录。包括读通话记录、写通话记录 -->
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />

当然,从 Android 6.0 开始,上述的通讯权限默认是关闭的,必须在运行 App 的时候动态申请相关权限,详细的权限申请过程参见上一小节的“运行时动态申请权限”。

尽管系统允许 App 通过内容解析器修改联系人列表,但操作过程比较繁琐,因为一个联系人可能有多个电话号码,还可能有多个邮箱,所以系统通讯录将其设计为 3 张表,分别是联系人基本信息表、联系号码表、联系邮箱表,于是每添加一位联系人,就要调用至少三次 insert 方法。下面是往手机通讯录添加联系人信息的代码例子:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\util\CommunicationUtil.java)

方式 1
java
// 往手机通讯录一次性添加一个联系人信息(包括主记录、姓名、电话号码、电子邮箱)
private void addFullContacts(ContentResolver resolver, Contact contact) {
    // 创建一个插入联系人主记录的内容操作器
    ContentProviderOperation op_main = ContentProviderOperation
            .newInsert(ContactsContract.RawContacts.CONTENT_URI)
            .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
            .build();

    // 创建一个插入联系人姓名记录的内容操作器
    ContentProviderOperation op_name = ContentProviderOperation
            .newInsert(ContactsContract.Data.CONTENT_URI)
            // 将第0个操作的id,即 raw_contacts 的 id 作为 data 表中的 raw_contact_id
            .withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
            .withValue(Contacts.Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
            .withValue(Contacts.Data.DATA2, contact.name)
            .build();

    // 创建一个插入联系人电话号码记录的内容操作器
    ContentProviderOperation op_phone = ContentProviderOperation
            .newInsert(ContactsContract.Data.CONTENT_URI)
            // 将第0个操作的id,即 raw_contacts 的 id 作为 data 表中的 raw_contact_id
            .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)
            // 将第0个操作的id,即 raw_contacts 的 id 作为 data 表中的 raw_contact_id
            .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> operations = new ArrayList<>();
    operations.add(op_main);
    operations.add(op_name);
    operations.add(op_phone);
    operations.add(op_email);

    try {
        // 批量提交四个操作
        resolver.applyBatch(ContactsContract.AUTHORITY, operations);
    } catch (OperationApplicationException e) {
        e.printStackTrace();
    } catch (RemoteException e) {
        e.printStackTrace();
    }
}
方式 2
java
// 往手机通讯录添加一个联系人信息(包括姓名、电话号码、电子邮箱)
public static void addContacts(ContentResolver resolver, Contact contact) {
    // 构建一个指向系统联系人提供器的 Uri 对象
    Uri raw_uri = Uri.parse("content://com.android.contacts/raw_contacts");
    ContentValues values = new ContentValues(); // 创建新的配对
    // 往 raw_contacts 添加联系人记录,并获取添加后的联系人编号
    long contactId = ContentUris.parseId(resolver.insert(raw_uri, values));

    // 构建一个指向系统联系人数据的 Uri 对象
    Uri uri = Uri.parse("content://com.android.contacts/data");
    ContentValues name = new ContentValues(); // 创建新的配对
    name.put("raw_contact_id", contactId); // 往配对添加联系人编号
    // 往配对添加“姓名”的数据类型
    name.put("mimetype", "vnd.android.cursor.item/name");
    name.put("data2", contact.name); // 往配对添加联系人的姓名
    resolver.insert(uri, name); // 往提供器添加联系人的姓名记录

    ContentValues phone = new ContentValues(); // 创建新的配对
    phone.put("raw_contact_id", contactId); // 往配对添加联系人编号
    // 往配对添加“电话号码”的数据类型
    phone.put("mimetype", "vnd.android.cursor.item/phone_v2");
    phone.put("data1", contact.phone); // 往配对添加联系人的电话号码
    phone.put("data2", "2"); // 联系类型。1 表示家庭,2 表示工作
    resolver.insert(uri, phone); // 往提供器添加联系人的号码记录

    ContentValues email = new ContentValues(); // 创建新的配对
    email.put("raw_contact_id", contactId); // 往配对添加联系人编号
    // 往配对添加“电子邮箱”的数据类型
    email.put("mimetype", "vnd.android.cursor.item/email_v2");
    email.put("data1", contact.email); // 往配对添加联系人的电子邮箱
    email.put("data2", "2"); // 联系类型。1 表示家庭,2 表示工作
    resolver.insert(uri, email); // 往提供器添加联系人的邮箱记录
}

同理,联系人读取代码也分成 3 个步骤,先查出联系人的基本信息,再依次查询联系人号码和联系人邮箱,详细代码参见 CommunicationUtil.java 的 readAllContacts 方法。

接下来演示联系人信息的访问过程,分别创建联系人的添加页面和查询页面,其中添加页面的完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\ContactAddActivity.java,查询页面的完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\ContactReadActivity.java。首先在添加页面输入联系人信息,点击添加按钮调用 addContacts 方法写入联系人数据,此时添加界面如图 7-5 所示。然后打开联系人查询页面,App 自动调用 readAllContacts 方法查出所有的联系人,并显示联系人列表如图 7-6 所示,可见刚才添加的联系人已经成功写入系统的联系人列表,而且也能正确读取最新的联系人信息。

raw_contacts 表

data 表

记录了用户的通讯录所有数据,包括手机号,显示名称等,但是里面的 mimetype_id 表示不同的数据类型,这与表 mimetypes 表中的 id 相对应,raw_contact_id 与下面的 raw_contacts 表中的 id 相对应。

mimetypes 表

利用 ContentObserver 监听短信

ContentResolver 获取数据采用的是主动查询方式,有查询就有数据,没查询就没数据。然而有时不但要获取或付款以往的数据,还要实时获取新增的数据,最常见的业务场景是短信验证码。电商 App 经常在用户注册或付款时发送验证码短信,为了替用户省事,App 通常会监控收集刚收到的短信验证码,并自动填写验证码输入框。这时就用到了内容观察器 ContentObserver,事先给目标内容注册一个观察器,目标内容的数据一旦发生变化,就马上触发观察器的监听事件,从而执行开发者预先定义的代码。

内容观察器的用法与内容提供其类似,也要从 ContentObserver 派生一个新的观察器,然后通过 ContentResolver 对象调用相应的方法注册或注销观察器。下面是内容解析器与内容观察器之间的交互方法说明。

  • registerContentObserver:内容解析器要注册内容观察器。
  • unregisterContentObserver:内容解析器要注销内容观察器。
  • notifyChange:通知内容观察器发生了数据变化,此时会触发观察器的 onChange 方法。 notifyChange 的调用时机参见“通过 ContentProvider 封装数据”的 insert 方法。

为了让读者更好理解,下面举一个实际应用的例子。手机号码的每月流量限额由移动运营商指定,以中国移动为例,只要将流量校准短信发给运营商客服号码(如发送 18 到 10086),运营商就会回复用户本月的流量数据,包括月流量额度、已使用流量、未使用流量等信息。手机 App 只需监控 10086 发来的短信内容,即可自动获取当前号码的流量详情。

下面是利用内容观察器实现流量校准的关键代码片段:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\MonitorSmsActivity.java)

java
package com.dongnaoedu.chapter07_client;

import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.database.ContentObserver;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.telephony.SmsManager;
import android.util.Log;
import android.view.View;
import android.widget.TextView;

import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;

public class MonitorSmsActivity extends AppCompatActivity implements View.OnClickListener {
    private final Handler mHandler = new Handler(); // 声明一个处理器对象
    private SmsGetObserver mObserver; // 声明一个短信获取的观察器对象
    private static Uri mSmsUri; // 声明一个系统短信提供器的 Uri 对象
    private static String[] mSmsColumn; // 声明一个短信记录的字段数组
    private static TextView tv_check_flow;
    private static String mCheckResult;

    // 初始化短信观察器
    private void initSmsObserver() {
        // mSmsUri = Uri.parse("content://sms/inbox");
        // Android 5.0 之后似乎无法单独观察某个信箱,只能监控整个短信
        mSmsUri = Uri.parse("content://sms"); // 短信数据的提供器路径
        // 短信记录的字段数组
        mSmsColumn = new String[]{"address", "body", "date"};
        // 创建一个短信观察器对象
        mObserver = new SmsGetObserver(this, mHandler);
        // 给指定 Uri 注册内容观察器,一旦发生数据变化,就触发观察器的 onChange 方法
        getContentResolver().registerContentObserver(mSmsUri, true, mObserver);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_monitor_sms);
        tv_check_flow = findViewById(R.id.tv_check_flow);
        tv_check_flow.setOnClickListener(this);
        findViewById(R.id.btn_check_flow).setOnClickListener(this);
        initSmsObserver();
    }

    @Override
    public void onClick(View v) {
        if (v.getId() == R.id.btn_check_flow) {
            // 查询数据流量,移动号码的查询方式为发送短信内容“18”给“10086”
            // 电信和联通号码的短信查询方式请咨询当地运营商客服热线
            // 跳到系统的短信发送页面,由用户手工发短信
            // sendSmsManual("10086", "002");
            // 无需用户操作,自动发送短信
            sendSmsAuto("10086", "002");
        } else if (v.getId() == R.id.tv_check_flow) {
            AlertDialog.Builder builder = new AlertDialog.Builder(this);
            builder.setTitle("收到流量短信")
                    .setMessage(mCheckResult)
                    .setPositiveButton("确定", null)
                    .create().show();
        }
    }

    // 跳到系统的短信发送页面,由用户手工编辑与发送短信
    public void sendManual(String phoneNumber, String message) {
        Intent intent = new Intent(Intent.ACTION_SENDTO, Uri.parse("smsto:" + phoneNumber));
        intent.putExtra("sms_body", message);
        startActivity(intent);
    }

    // 短信发送事件
    private String SENT_SMS_ACTION = "com.example.storage.SENT_SMS_ACTION";
    // 短信接收事件
    private String DELIVERED_SMS_ACTION = "com.example.storage.DELIVERED_SMS_ACTION";
    // 无需用户操作,由 App 自动发送短信
    public void sendSmsAuto(String phoneNumber, String message) {
        // 以下指定短信发送事件的详细信息
        Intent sendIntent = new Intent(SENT_SMS_ACTION);
        sendIntent.putExtra("phone", phoneNumber)
                .putExtra("message", message);
        PendingIntent sendPI = PendingIntent.getBroadcast(this, 0, sendIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
        // 以下指定短信接收事件的详细信息
        Intent deliverIntent = new Intent(DELIVERED_SMS_ACTION);
        deliverIntent.putExtra("phone", phoneNumber)
                .putExtra("message", message);
        PendingIntent deliverPI = PendingIntent.getBroadcast(this, 1, deliverIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
        // 获取默认的短信管理器
        SmsManager smsManager = SmsManager.getDefault();
        // 开始发送短信内容。要确保打开发送短信的完全权限,不是那种还需提示的不完整权限
        smsManager.sendTextMessage(phoneNumber, null, message, sendPI, deliverPI);
    }

    // 在页面销毁时触发
    @Override
    protected void onDestroy() {
        super.onDestroy();
        getContentResolver().unregisterContentObserver(mObserver); // 注销内容观察器
    }

    // 定义一个短信获取的观察器
    private static class SmsGetObserver extends ContentObserver {

        private final Context mContext; // 声明一个上下文对象

        public SmsGetObserver(Context context, Handler handler) {
            super(handler);
            this.mContext = context;
        }

        // 观察到短信的内容提供器发生变化时触发
        @SuppressLint("Range")
        @Override
        public void onChange(boolean selfChange, @Nullable Uri uri) {
            // onChange会多次调用,收到一条短信会调用两次onChange
            // mUri===content://sms/raw/20
            // mUri===content://sms/inbox/20
            // 安卓7.0以上系统,点击标记为已读,也会调用一次
            // mUri===content://sms
            // 收到一条短信都是uri后面都会有确定的一个数字,对应数据库的_id,比如上面的20
            if (uri == null) {
                return;
            }
            if (uri.toString().contains("content://sms/raw") ||
                    uri.toString().equals("content://sms")) {
                return;
            }
            Log.i("ning", "onChange: 触发::" + uri.toString());
            String sender = "", content = "";
            // 构建一个欻有短信的条件语句,移动号码要查找 10086 发来的短信
            // 查找最近一分钟的短信
            String selection = String.format("address='10086' and date>%d", System.currentTimeMillis() - 1000 * 60 * 1);
            // 通过内容解析器获取符合条件的结果集游标
            Cursor cursor = mContext.getContentResolver().query(mSmsUri, mSmsColumn, selection, null, " date desc");
            // 循环去除游标所指向的所有短信记录
            while (cursor.moveToNext()) {
                sender = cursor.getString(0); // 短信的发送号码
                content = cursor.getString(1); // 短信内容
                break;
            }
            cursor.close(); // 关闭数据库游标
            mCheckResult = String.format("发送号码:%s\n短信内容:%s", sender, content);
            // 依次解析流量校准短信里面的各项流量数值,并拼接流量校准的结果字符串
            String flow = String.format("流量校准结果如下:总流量为:%s;已使用:%s;剩余流量:%s",
                    findFlow(content, "总流量为"),
                    findFlow(content, "已使用"),
                    findFlow(content, "剩余"));
            if (tv_check_flow != null) { // 离开该页面后就不再显示流量信息
                tv_check_flow.setText(flow);
            }
            super.onChange(selfChange, uri);
        }
    }

    // 解析流量短信里面的流量数值
    private static String findFlow(String sms, String begin) {
        String flow = findString(sms, begin, "GB");
        String temp = flow.replace("GB", "").replace(".", "");
        if (!temp.matches("\\d+")) {
            flow = findString(sms, begin, "MB");
        }
        return flow;
    }

    // 截取指定头尾之间的字符串
    private static String findString(String content, String begin, String end) {
        int begin_pos = content.indexOf(begin);
        if (begin_pos < 0) {
            return "未获取";
        }
        String sub_sms = content.substring(begin_pos);
        int end_pos = sub_sms.indexOf(end);
        if (end_pos < 0) {
            return "未获取";
        }
        if (end.equals(",")) {
            return sub_sms.substring(begin.length(), end_pos);
        } else {
            return sub_sms.substring(begin.length(), end_pos + end.length());
        }
    }
}

运行测试 App,点击校准按钮发送流量校准短信,接着收到如图 7-7 所示的短信内容。可见通过内容观察器实时获取了最新的短信记录。

总结一下系统开放给普通应用访问的常用 URI,详细的 URI 取值说明见表 7-2。

内容名称URI 常用名实际路径
联系人基本信息ContactsContract.Contacts.CONTENT_URIcontent://com.android.contacts/contacts
联系人电话号码ContactsContract.CommonDataKinds.Phone.CONTENT_URIcontent://com.android.contacts/data/phones
联系人邮箱ContactsContract.CommonDataKinds.Email.CONTENT_URIcontent://com.android.contacts/data/emails
短信Telephony.Sms.CONTENT_URIcontent://sms
彩信Telephony.Sms.CONTENT_URIcontent://mms
通话记录CallLog.Calls.CONTENT_URIcontent://call_log/calls

在应用之间共享文件

本节介绍了 Android 在应用共享文件的几种方式,包括:如何使用系统相册发送带图片的彩信,如何从相册媒体库获取图片并借助 FileProvider 发送彩信,如何在媒体库中查找 APK 文件并借助 FileProvider 安装应用。

使用相册图片发送彩信

不同应用之间可以共享数据,当然也能共享文件,比如系统相册保存着用户拍摄的照片,这些照片理应分享给其他 App 使用。举个例子,短信只能发送文本,而彩信允许同时发送文本和图片,彩信的附件图片就来自系统相册。现在准备到系统相册挑选照片,测试页面的 Java 代码先增加以下两行代码,分别声明一个路径对象和选择照片的请求码:

java
private Uri mUri; // 文件的路径对象
private int CHOOSE_CODE = 3; // 选择照片的请求码

接着在选取按钮的点击方法中加入下面代码,表示打开系统相册选择照片:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07_client\SendMmsActivity.java)

java
// 创建一个内容获取动作的意图
Intent albumIntent = new Intent(Intent.ACTION_GET_CONTENT);
albumIntent.setType("image/*"); // 设置内容类型为图像
startActivityForResult(albumIntent, CHOOSE_CODE); // 打开系统相册,并等待图片选择结果

上面的跳转代码期望接收照片选择结果,于是重写当前活动的 onActivityResult 方法,调用返回意图的 getDate 方法获得选中照片的路径对象,重写后的方法代码如下所示:

java
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent intent) {
    super.onActivityResult(requestCode, resultCode, intent);
    if (resultCode == RESULT_OK && requestCode == CHOOSE_CODE) { // 从相册选择一张照片
        if (intent.getData() != null) { // 数据非空,表示选中了某张照片
            mUri = intent.getData(); // 获得选中照片的路径对象
            iv_appendix.setImageURI(mUri); // 设置图像视图的路径对象
            Log.d(TAG, "uri.getpath = " + mUri.getPath() + ", uri.toString = " + mUri.toString());
        }
    }
}

这下拿到了相册照片的路径对象,既能把它显示到图像视图,也能将它作为图片附件发送彩信了。由于普通应用无法自行发送彩信,必须打开系统的信息应用才行,于是编写页面跳转代码,往意图对象塞入详细的彩信数据,包括彩信发送的目标号码、标题、内容,以及 Uri 类型的图片附件。详细的跳转代码示例如下:

java
// 发送带图片的彩信
private void sendMms(String phone, String title, String message) {
    Intent intent = new Intent(Intent.ACTION_SEND); // 创建一个发送动作的意图
    intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // 需要读权限
    intent.putExtra("address", phone); // 彩信发送的目标号码
    intent.putExtra("subject", title); // 彩信的标题
    intent.putExtra("sms_body", message); // 彩信的内容
    intent.putExtra(Intent.EXTRA_STREAM, mUri); // mUri 为彩信的图片附件
    intent.setType("image/*"); // 彩信的附件为图片
    // 部分手机无法直接跳到彩信发送页面,孤儿需要用户手动选择彩信应用
    // intent.setClassName("com.android.mms", "com.android.mms.ui.ComposeMessageActivity");
    startActivity(intent); // 因为未指定要打开哪个页面,所以系统会在底部弹出选择窗口
    ToastUtil.show(this, "请在弹窗中选择短信或者信息应用");
}

运行测试 App,刚打开的活动页面如图 7-9 所示,在各行编辑框中一次填写彩信的目标号码、标题、内容,再到系统相册选取照片,填好的界面效果如图 7-10 所示。

之后点击发送按钮,屏幕下方弹出如图 7-11 所示的应用选择窗口。

先点击信息图标,表示希望跳到信息应用,再点击“仅此一次”按钮,此时打开信息应用界面如图 7-12 所示。可见信息发送界面已经自动填充收件人号码、信息标题和内容,以及图片附件,只待用户轻点右下角的飞鸽传书图标,就能将彩信发出去了。

借助 FileProvider 发送彩信

通过系统相册固然可以获得照片的路径对象,却无法知晓更多的详细信息,例如照片名称、文件大小、文件路径等信息,也就无法进行个性化的定制开发。为了把更多的文件信息开放出来,Android 设计了专门的媒体共享库,允许开发者通过内容组件从中获取更详细的媒体信息。

图片所在的相册媒体库路径为 MediaStore.Images.Media.EXTERNAL_CONTENT_URI,通过内容解析器即可从媒体库依次遍历得到图片列表详情。为便于代码管理,首先要声明如下的对象变量:

(完整的 ImageInfo 代码见 chapter07_client\src\main\java\com\example\chapter07\bean\ImageInfo.java)

java
private List<ImageInfo> mImageList = new ArrayList<>(); // 图片列表
private Uri mImageUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; // 相册的 Uri
private String[] mImageColumn = new String[]{ // 媒体库的字段名称数组
    MediaStore.Images.Media._ID, // 编号
    MediaStore.Images.Media.TITLE, // 标题
    MediaStore.Images.Media.SIZE, // 文件大小
    MediaStore.Images.Media.DATA // 文件路径
};

然后使用内容解析器查询媒体库的图片信息,简单起见只挑选文件大小最小的前 6 张图片,图片列表加载代码示例如下:

(完整代码见 chapter07_client\src\main\java\com\example\chapter07\ProviderMmsActivity.java)

java
private void loadImageList() {
    //MediaStore
    String[] columns = new String[]{
            MediaStore.Images.Media._ID, // 编号
            MediaStore.Images.Media.TITLE, // 标题
            MediaStore.Images.Media.SIZE,// 文件大小
            MediaStore.Images.Media.DATA,// 文件路径
    };
    // 图片大小在300KB以内
    Cursor cursor = getContentResolver().query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            columns,
            "_size < 307200",
            null,
            "_size DESC"
    );
    int count = 0;
    if (cursor != null) {
        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));
            if (FileUtil.checkFileUri(this, image.path)) {
                count++;
                mImageList.add(image);
            }
            Log.d("ning", "image:" + image.toString());
        }
        cursor.close();
    }
}

注意到以上代码获得了字符串格式的文件路径,而彩信发送应用却要求 Uri 类型的路径对象,原本可以通过代码Uri.parse(path)将字符串转换为 Uri 对象,但是从 Android7.0 开始,系统不允许其他应用直接访问老格式的路径,必须使用文件提供器 FileProvider 才能获取合法的 Uri 路径,相当于 A 应用申明了共享某个文件,然后 B 应用方可访问该文件。为此需要重头配置 FileProvider,详细的配置步骤说明如下。

首先在 res 目录新建 xml 文件夹,并在该文件夹中创建 file_paths.xml,再往 XML 文件填入以下内容,表示定义几个外部文件目录:

xml
<paths>
    <external-path path="Android/data/com.example.chapter07/" name="files_root" />
    <external-path path="." name="external_storage_root" />
</paths>

接着打开 AndroidManifest.xml,在 application 节点内部添加下面的 provider 标签,表明声明当前应用的内容提供器组件,添加后的标签配置示例如下:

xml
<!-- 兼容 Android7.0,把访问文件的 Uri 方式改为 FileProvider -->
<provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="@string/file_provider"
        android:export="false"
        android:grantUriPermissions="true" >
    <meta-data 
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

上面的 provider 有两处地方允许修改,一处是 authorities 属性,它规定了授权字符串,这是每个提供器的唯一标识;另一处是元数据的 resource 属性,它指明了文件提供器的路径资源,也就是刚才定义的 file_paths.xml。

回到活动页面的源码,在发送彩信之前添加下述代码,目的是根据字符串路径构建 Uri 对象,注意针对 Android7.0 以上的兼容处理。

(完整代码见 ProviderMmsActivity.java 的 sendMms 方法)

java
Uri uri = Uri.parse(path); // 根据指定路径创建一个 Uri 对象
// 兼容 Android7.0,把访问文件的 Uri 方式改为 FileProvider
if (Build.VERSION.SDK_INT >= Build.VERSION_CODE.N) {
    // 通过 FileProvider 获得文件的 Uri 访问方式
    uri = FileProvider.getUriForFile(this, "com.example.chapter07.fileProvider", new File(path));
}

由以上代码可见,Android7.0 开始调用 FileProvider 的 getUriForFile 方法获得 Uri 对象,该方法的第二个参数为文件提供器的授权字符串,第三个参数为 File 类型的文件对象。

运行测试 App,页面会自动加载媒体库的前 6 张图片,另外手工输入对方号码、彩信标题、彩信内容等信息,填好的发送界面如图 7-13 所示。

点击页面下方的某张图片,表示选中该图片作为彩信附件,此时界面下方弹出如图 7-14 所示的应用选择窗口。选中信息图片再点击“仅此一次”按钮,即可跳到如图 7-15 所示的系统信息发送页面了。

借助 FileProvider 安装应用

除了发送彩信需要文件提供器,安装应用也需要 FileProvider。不单单彩信的附件图片能到媒体库中查询,应用的 APK 安装包也可在媒体库找到,查找安装包依然借助于内容解析器,具体的实现过程和查询图片类似,比如事先声明如下的对象变量:

(完整的 ApkInfo 代码见 chapter07_client\src\main\java\com\example\chapter07\bean\ApkInfo.java)

java
private List<ApkInfo> mApkList = new ArrayList<ApkInfo>(); // 安装包列表
private Uri mFilesUri = MediaStore.Files.getContentUri("external"); // 存储卡的 Uri
private String[] mFilesColumn = new String[]{
    MediaStore.Files.FileColumns._ID, // 编号
    MediaStore.Files.FileColumns.TITLE, // 标题
    MediaStore.Files.FileColumns.SIZE, // 文件大小
    MediaStore.Files.FileColumns.DATA, // 文件路径
    MediaStore.Files.FileColumns.MIME_TYPE // 媒体类型
};

再通过内容解析器到媒体库查找安装包列表,具体的加载代码示例如下:

(完整代码见 chapter07_client\src\main\jav\com\example\chapter07\ProviderApkActivity.java)

java
// 加载安装包列表
private void loadApkList() {
    mApkList.clear(); // 清空安装包列表
    // 吹从存储卡上所有的 apk 文件,其中 mime_type 制定了 APK 的文件类型,或者判断文件路径是否以 .apk 结尾
    Cursor cursor = getContentResolver().query(mFilesUri, mFilesColumn, "mime_type='application/vnd.android.package-archive' or _data like '%.apk'", null, null);
    if (cursor != null) {
        // 下面便利结果集,并逐个添加到安装包列表。简单起见只挑前十个文件
        for (int i = 0; i < 10 && cursor.moveToNext(); i++) {
            ApkInfo apk = new ApkInfo(); // 创建一个安装包信息列表
            apk.setId(cursor.getLong(0)); // 设置安装包编号
            apk.setName(cursor.getString(1)); // 设置安装包名称
            apk.setSize(cursor.getLong(2)); // 设置安装包的大小
            apk.setPath(cursor.getString(3)); // 设置安装包的文件路径
            Log.d(TAG, apk.getName() + "," + apk.getSize() + "," + apk.getPath() + "," + cursor.getString(4));
            if (!FileUtil.checkFileUri(this, apk.getPath())) { // 检查该路径是否合法
                i--;
                continue; // 路径非法则再来一次
            }
            mApkList.add(apk); // 添加至安装包列表
        }
        cursor.close(); // 关闭数据库游标
    }
}

找到安装包之后,通常还要获取它的包名、版本名称、版本号等信息,此时可调用应用包管理器的 getPackageArchiveInfo 方法,从安装包文件中提取 PackageInfo 包信息。包信息对象的 packageName 属性值为应用包名,versionName 属性值为版本名称,versionCode 属性值为版本号。下面是利用弹窗展示包信息的代码例子:

java
// 显示安装 apk 的提示对话框
private void showAlert(final ApkInfo apkInfo) {
    PackageManager pm = getPackageManager(); // 获取应用包管理器
    // 获取 apk 文件的包信息
    PackageInfo pi = pm.getPackageArchiveInfo(apkInfo.getPath(), PackageManager.GET_ACTIVITIES);
    if (pi != null) { // 能找到包信息
        Log.d(TAG, "packageName=" + pi.packageName + ",versionName=" + pi.versionName + ",versionCode=" + pi.versionCode);
        String desc = String.format("应用包名:%s\n版本名称:%s\n版本编码:%s\n文件路径:%s", pi.packageName, pi.versionName, pi.versionCode, apkInfo.getPath());
        AlertDialog.Builder builder = new AlertDialog.Builder(this);
        builder.setTitle("是否安装该应用?"); // 设置提醒对话框的标题
        builder.setMessage(desc); // 设置提醒对话框的消息内容
        builder.setPositiveButton("是", new DialogInterface.OnClickListener() {
            @Override
            public void onClick(DialogInterface dialog, int which) {
                installApk(apkInfo.getPath()); // 安装指定路径的 APK
            }
        });
        builder.setNegativeButton("否", null);
        builder.create().show(); // 显示提醒对话框
    } else {
        ToastUtil.show(this, "该安装包已经损坏,请选择其他安装包");
    }
}

有了安装包的文件路径之后,就能打开系统自带的安装程序执行安装操作了,此时一样要把安装包的 Uri 对象传过去。应用安装的详细调用代码如下所示:

java
// 安装指令路径的 APK
private void installApk(String path) {
    Log.d(TAG, "path=" + path);
    Uri uri = Uri.parse(path); // 根据指定路径创建一个 Uri 对象
    // 兼容 Android7.0,把访问文件的 Uri 方式改为 FileProvider
    if (Build.VERSION.SDK_INT >= BUILD.VERSION_CODES.N) {
        // 通过 FileProvider 获得安装包文件的 Uri 访问方式
        uri = FileProvider.getUriForFile(this, getPackageName() + ".fileProvider", new File(path));
    }
    Intent intent = new Intent(Intent.ACTION_VIEW); // 创建一个浏览动作的意图
    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // 另外开启新页面
    intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); // 需要读权限
    // 设置 Uri 的数据类型为 APK 文件
    intent.setDataAndType(uri, "application/vnd.android.package-archive");
    startActivity(intent); // 启动系统自带的应用安装程序
}

注意,从 Android8.0 开始,安装应用需要申请权限 REQUEST_INSTALL_PACKAGES,于是打开 AndroidManifest.xml,补充下面的权限申请配置:

xml
<!-- 安装应用请求,Android8.0 需要 -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />

这下大功告成,编译运行 App,打开测试页面自动加载的界面如图 7-16 所示。点击按钮,弹出如图 7-17 所示的确认对话框。

点击确认对话框的“是”按钮,便跳到了如图 7-18 所示的应用安装界面,点击“允许”按钮之后,剩下的安装操作就交给系统程序了。

小结

本章主要介绍内容组件——ContentProvider 的常见用法,包括:在应用之间共享数据(通过 ContentProvider 封装数据、通过 ContentResolver 访问数据)、使用内容组件获取通讯信息(运行时动态申请权限、利用 ContentResolver 读写联系人、利用 ContentObserver 监听短信)、在应用之间共享文件(使用相册照片发送彩信、借助 FileProvider 发送彩信、借助 FileProvider 安装应用)。

通过本章的学习,我们应该能掌握以下 4 种开发技能:

  1. 学会利用 ContentProvider 在应用之间共享数据。
  2. 学会在 App 运行过程中动态申请权限。
  3. 学会使用内容组件获取系统的通讯信息。
  4. 学会利用 FileProvider 在应用之间共享文件。