当前位置: 首页 > 工具软件 > SAF > 使用案例 >

SAF的使用

那谦
2023-12-01

一、前言

在Android上面对存储空间的访问目前主要有两种方式:MediaStoreSAFMediaStore主要是用来对媒体文件做存储处理。SAF主要是用来对文档的目录进行处理。当我们查找某种条件比较具体的文件时候可以使用MediaStore–例如查找整个应用中的apk文件。但是如果我们需要查找或修改某个文件夹中的内容,或者查看某个文件时候,使用SAF较好。但是MediaStore使用不需要权限(如果访问外置存储卡而不是图片这种地址的话依然需要授权),而SAF需要经过用户授权

二、注意问题

使用SAF的方式和使用File有点不太一样。

  1. File可以通过当前文件获取父文件,然后再获取同级文件。但是SAF就不能这样,只能获取到授权的文件夹下面的文件。获取父文件夹的话都是null
  2. SAF没有经过授权的话,无法获取到该文文件夹。哪怕知道具体的uri
  3. SAF授权过的文件夹、,即使把程序卸载再装上,哪怕没有再次授权依然可以通过uri获取到该文件
  4. SAF在11.0及其以下可以获取到存储卡中的根目录,但是12.0的话无法获取到外置存储卡的根目录。

三、路径命名规则

SAF整体内容较多,在实践过程中只涉及了其中一部分。这里对这一部分进行详细记录。需求如下,获取根目录下面的Whatsapp目录下面的内容,由于需要授权问题,所以需要将权限进行保留,以后每次使用时候也要进行权限判断(这里使用DataStore-proto进行权限保存,关于DataStore-proto使用参考Android中的DataStore-Proto_Mr_Tony的专栏-CSDN博客)。

路径定义如下:

    //通过SAF方式进行文档读写,文档格式为
    // content://com.example/root:sdcard/recent
    // 不能为以下方式,其中冒号需要用UrlEncode编码,其编码为 %3A
    // content://com.example/root/sdcard/recent/
    // 最终需要转为Uri进行使用const val SAF_ROOT = "content://com.android.externalstorage.documents/tree/primary%3A"
    const val SAF_ROOT = "content://com.android.externalstorage.documents/tree/primary%3A"
    const val SAF_WHATS_APP = SAF_ROOT+"WhatsApp" //WhatsApp子目录Media

四、添加依赖

../app/build.gradle中添加以下依赖

plugins {
    id "com.google.protobuf" version "0.8.12"
}
dependencies {

    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'androidx.appcompat:appcompat:1.4.0'
    coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
    implementation 'androidx.activity:activity-ktx:1.4.0'
    implementation 'androidx.fragment:fragment-ktx:1.4.0'
    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.4.0"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0"
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.4.0"
    implementation 'androidx.documentfile:documentfile:1.0.1'
    //协程
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1'
    //dataStore安全类型存储
    implementation "androidx.datastore:datastore:1.0.0"
    implementation  "com.google.protobuf:protobuf-javalite:3.14.0"
}
protobuf {
    protoc {
        if (osdetector.os == "osx") {//m1芯片需要单独处理
            artifact = 'com.google.protobuf:protoc:3.14.0:osx-x86_64'
        } else {
            artifact = 'com.google.protobuf:protoc:3.14.0'
        }
    }

    // Generates the java Protobuf-lite code for the Protobufs in this project. See
    // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation
    // for more information.
    generateProtoTasks {
        all().each { task ->
            task.builtins {
                java {
                    option 'lite'
                }
            }
        }
    }
}

五、授权

权限跳转需要使用Intent跳转到新的页面进行授权。这里通过使用ResultApi进行授权。这里将该功能单独写了个模块使其与其它业务分离

object ResultApiRoute {
    const val FLAG_DOCUMENT = 5 //用于标志document文件
    class ResultSAFStorePermissionContact: ActivityResultContract<Uri, Uri>() {
        private var mContext: Context ?= null
        //创建跳转的Intent
        override fun createIntent(context: Context, input: Uri): Intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
            flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION //授予打开权限类型
            putExtra(DocumentsContract.EXTRA_INITIAL_URI,input)//授予目录路径
            mContext = context
        }

        override fun parseResult(resultCode: Int, intent: Intent?): Uri {
            val dirUri = intent?.data ?: Uri.EMPTY
            mContext?.contentResolver?.takePersistableUriPermission(//获取永久授权
                dirUri,
                Intent.FLAG_GRANT_READ_URI_PERMISSION
            )
            return dirUri
        }
    }
    //用于获取SAF中指定目录的访问权限
    //注意跳转时机不要在刚注册就去跳转
    class ResultSAFStorePermissionObserver(private val registry : ActivityResultRegistry,private val flag: Int,private val initCallBack: (ResultSAFStorePermissionObserver) -> Unit, private val callback: ActivityResultCallback<Uri>)
        : DefaultLifecycleObserver {
        private lateinit var getContent : ActivityResultLauncher<Uri>

        override fun onCreate(owner: LifecycleOwner) {
            //这里使用flag将key区分,如果是同一个key的话只会注册一个,即使重复创建新的构造函数也是如此
            getContent = registry.register(flag.toString(), owner,ResultSAFStorePermissionContact(),callback)
            initCallBack.invoke(this)
        }

        //查看视频详情将原先到数据传递过来并返回选择的数据
        fun goSAFStoreActivity(uri: Uri) {
            getContent.launch(uri)
        }
    }
}

六、使用DataStore-proto保存权限

../app/src/main/下面创建proto的文件夹,在下面创建datastore.proto的文件。内容如下:

syntax = "proto3";

option java_package = "com.test.datastore";
option java_multiple_files = true;

message SAFDataStore {
  bool hasSAFPermission = 1;//是否有saf授权
}

重新build生成新的文件。编写kotlin代码,如下:

//RockeyDataStore的序列化操作
//写法参考
//https://developer.android.google.cn/topic/libraries/architecture/datastore?hl=zh-cn
object SAFSerializer: Serializer<SAFDataStore> {
    override val defaultValue: SAFDataStore
        get() = SAFDataStore.getDefaultInstance()

    override suspend fun readFrom(input: InputStream): SAFDataStore {
        try {
            return SAFDataStore.parseFrom(input)
        }catch (exception: InvalidProtocolBufferException){
            throw CorruptionException("Cannot read proto.", exception)
        }
    }

    override suspend fun writeTo(t: SAFDataStore, output: OutputStream) {
        t.writeTo(output)
    }
}

创建全局使用单例:

//存储类型管理
val Context.safDataStore: DataStore<SAFDataStore> by dataStore(
    fileName = "datastore.proto",
    serializer = RockeySerializer
)

七、ViewModel中对授权功能对使用

class SAFStoreViewModel : ViewModel(){
    //跳转到SAF文档管理页面的监听
    private var filePermissionObserver: ResultApiRoute.ResultSAFStorePermissionObserver? = null
    /**
     * 检查文档的权限
     * 这里将是否授权存在本地,这样就避免了每次去授权
     * 该函数需要在onStart生命周期之前调用
     * @param uri: 检查目标文件的授权
     */
    private fun checkDocumentPermission(context: FragmentActivity, uri: Uri) {
        val handler = CoroutineExceptionHandler { _, exception ->
            println("CoroutineExceptionHandler got $exception with suppressed ${exception.suppressed.contentToString()}")
            exception.printStackTrace()
        }//注册个协程异常处理器监听异常
        val content = handler + Dispatchers.IO
        viewModelScope.launch(context = content) {
            val hasPermission: Boolean = context.rockeyDataStore.data.map {
                it.hasSAFPermission
            }.catch { e ->
                e.printStackTrace()
            }
            .first()
            if (hasPermission){
                //已经获取授权
            }else{//没有授权则进行授权获取
                filePermissionObserver?.goSAFStoreActivity(uri)
            }
        }
    }
    //更新文档权限授权方式
    private suspend fun updateDocumentPermission(context: Context, uri: Uri){
        val isHasPermission = uri != Uri.EMPTY
        Log.e("YM--->存入的授权","-->hasPermission:$isHasPermission")
        context.safDataStore.updateData {
            it.toBuilder()
                .setHasSAFPermission(isHasPermission)
                .build()
        }
    }
/**
     * 该函数需要在STARTED生命周期之前调用
     * 必须用在主线程中
     */
    @MainThread
    fun addObserver(act: FragmentActivity, uri: Uri) {
        filePermissionObserver = ResultApiRoute.ResultSAFStorePermissionObserver(act.activityResultRegistry,
            ResultApiRoute.FLAG_DOCUMENT,
            {
                //注册成功后回调用
                checkDocumentPermission(act, uri)
            },
            {//权限授予结果后回调,注意该逻辑中没有处理权限拒绝的情况
                viewModelScope.launch(context = Dispatchers.IO) {
                    updateDocumentPermission(act,uri)
                }
            })
        act.lifecycle.addObserver(filePermissionObserver!!)
    }
}

八、在Activity中的使用

class MainActivity: AppCompatActivity() {
    private val viewModel: SAFStoreViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        viewModel.addObserver(this,Uri.parse(SAF_WHATS_APP))
    }
}

九、参考链接

  1. Android中的DataStore-Proto_Mr_Tony的专栏-CSDN博客

  2. Android中的ResultApi跳转_Mr_Tony的专栏-CSDN博客

 类似资料: