├── .gitignore ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── zl │ │ └── weilu │ │ └── saf │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── zl │ │ │ └── weilu │ │ │ └── saf │ │ │ └── MainActivity.java │ └── res │ │ ├── drawable-v24 │ │ └── ic_launcher_foreground.xml │ │ ├── drawable │ │ └── ic_launcher_background.xml │ │ ├── layout │ │ └── activity_main.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.png │ │ └── ic_launcher_round.png │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── styles.xml │ └── test │ └── java │ └── com │ └── zl │ └── weilu │ └── saf │ └── ExampleUnitTest.java ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SAFExample 2 | 3 | SAF(Storage Access Framework)使用例子 4 | 5 | ## 具体说明 6 | 7 | - [SAF(Storage Access Framework)使用攻略](https://weilu.blog.csdn.net/article/details/104199446) 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | compileSdkVersion 30 5 | defaultConfig { 6 | applicationId "com.zl.weilu.saf" 7 | minSdkVersion 21 8 | targetSdkVersion 30 9 | versionCode 3 10 | versionName "1.2.0" 11 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 12 | } 13 | buildTypes { 14 | release { 15 | minifyEnabled false 16 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 17 | } 18 | } 19 | } 20 | 21 | dependencies { 22 | implementation fileTree(dir: 'libs', include: ['*.jar']) 23 | implementation 'androidx.appcompat:appcompat:1.2.0' 24 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 25 | implementation 'androidx.documentfile:documentfile:1.0.1' 26 | testImplementation 'junit:junit:4.13.1' 27 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 28 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 29 | } 30 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/zl/weilu/saf/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.zl.weilu.saf; 2 | 3 | import android.content.Context; 4 | 5 | import androidx.test.platform.app.InstrumentationRegistry; 6 | import androidx.test.ext.junit.runners.AndroidJUnit4; 7 | 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | 11 | import static org.junit.Assert.*; 12 | 13 | /** 14 | * Instrumented test, which will execute on an Android device. 15 | * 16 | * @see Testing documentation 17 | */ 18 | @RunWith(AndroidJUnit4.class) 19 | public class ExampleInstrumentedTest { 20 | @Test 21 | public void useAppContext() { 22 | // Context of the app under test. 23 | Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); 24 | 25 | assertEquals("com.zl.weilu.saf", appContext.getPackageName()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/java/com/zl/weilu/saf/MainActivity.java: -------------------------------------------------------------------------------- 1 | package com.zl.weilu.saf; 2 | 3 | import android.app.Activity; 4 | import android.content.Context; 5 | import android.content.Intent; 6 | import android.content.SharedPreferences; 7 | import android.database.Cursor; 8 | import android.net.Uri; 9 | import android.os.Bundle; 10 | import android.provider.DocumentsContract; 11 | import android.provider.MediaStore; 12 | import android.text.TextUtils; 13 | import android.view.View; 14 | import android.widget.ImageView; 15 | import android.widget.TextView; 16 | import android.widget.Toast; 17 | 18 | import androidx.appcompat.app.AppCompatActivity; 19 | import androidx.documentfile.provider.DocumentFile; 20 | 21 | import java.io.BufferedReader; 22 | import java.io.FileNotFoundException; 23 | import java.io.IOException; 24 | import java.io.InputStream; 25 | import java.io.InputStreamReader; 26 | import java.io.OutputStream; 27 | import java.nio.charset.StandardCharsets; 28 | import java.util.Objects; 29 | 30 | public class MainActivity extends AppCompatActivity { 31 | 32 | public static int READ_REQUEST_CODE = 10001; 33 | public static int WRITE_REQUEST_CODE = 10002; 34 | public static int REQUEST_CODE_FOR_DIR = 10003; 35 | 36 | private final String[] IMAGE_PROJECTION = { 37 | MediaStore.Images.Media.DISPLAY_NAME, 38 | MediaStore.Images.Media.SIZE, 39 | MediaStore.Images.Media._ID }; 40 | 41 | private ImageView image; 42 | private TextView mInfo; 43 | 44 | private Uri mFileUri; 45 | 46 | @Override 47 | protected void onCreate(Bundle savedInstanceState) { 48 | super.onCreate(savedInstanceState); 49 | setContentView(R.layout.activity_main); 50 | image = findViewById(R.id.image); 51 | mInfo = findViewById(R.id.info); 52 | } 53 | 54 | public void selectSingleImage(View view) { 55 | //通过系统的文件浏览器选择一个文件 56 | // Android 11限制:使用 ACTION_OPEN_DOCUMENT_TREE 或 ACTION_OPEN_DOCUMENT,无法浏览到Android/data/ 和 Android/obb/ 目录 57 | Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); 58 | //筛选,只显示可以“打开”的结果,如文件(而不是联系人或时区列表) 59 | intent.addCategory(Intent.CATEGORY_OPENABLE); 60 | //过滤只显示图像类型文件 61 | intent.setType("image/*"); 62 | startActivityForResult(intent, READ_REQUEST_CODE); 63 | } 64 | 65 | public void createFile(View view) { 66 | Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); 67 | intent.addCategory(Intent.CATEGORY_OPENABLE); 68 | intent.setType("text/plain"); 69 | intent.putExtra(Intent.EXTRA_TITLE, System.currentTimeMillis() + ".txt"); 70 | startActivityForResult(intent, WRITE_REQUEST_CODE); 71 | } 72 | 73 | public void openFile(View view) { 74 | if (mFileUri != null) { 75 | StringBuilder stringBuilder = new StringBuilder(); 76 | InputStream inputStream = null; 77 | try { 78 | inputStream = getContentResolver().openInputStream(mFileUri); 79 | BufferedReader reader = new BufferedReader(new InputStreamReader(Objects.requireNonNull(inputStream))); 80 | String line; 81 | while ((line = reader.readLine()) != null) { 82 | stringBuilder.append(line); 83 | } 84 | if (TextUtils.isEmpty(stringBuilder.toString())) { 85 | Toast.makeText(this, "内容为空!", Toast.LENGTH_SHORT).show(); 86 | } 87 | mInfo.setText(stringBuilder.toString()); 88 | } catch (IOException e) { 89 | Toast.makeText(this, "读取文件失败!", Toast.LENGTH_SHORT).show(); 90 | } finally { 91 | if (inputStream != null) { 92 | try { 93 | inputStream.close(); 94 | } catch (IOException e) { 95 | e.fillInStackTrace(); 96 | } 97 | } 98 | } 99 | } else { 100 | Toast.makeText(this, "请先创建测试文件!", Toast.LENGTH_SHORT).show(); 101 | } 102 | } 103 | 104 | public void updateFile(View view) { 105 | if (mFileUri != null) { 106 | OutputStream outputStream = null; 107 | try { 108 | // 获取 OutputStream 109 | outputStream = getContentResolver().openOutputStream(mFileUri); 110 | outputStream.write("Storage Access Framework Example".getBytes(StandardCharsets.UTF_8)); 111 | } catch (IOException e) { 112 | Toast.makeText(this, "修改文件失败!", Toast.LENGTH_SHORT).show(); 113 | } finally { 114 | if (outputStream != null) { 115 | try { 116 | outputStream.close(); 117 | } catch (IOException e) { 118 | e.fillInStackTrace(); 119 | } 120 | } 121 | } 122 | Toast.makeText(this, "修改文件内容完成!", Toast.LENGTH_SHORT).show(); 123 | } else { 124 | Toast.makeText(this, "请先创建测试文件!", Toast.LENGTH_SHORT).show(); 125 | } 126 | } 127 | 128 | public void deleteFile(View view) { 129 | if (mFileUri != null) { 130 | try { 131 | DocumentsContract.deleteDocument(getContentResolver(), mFileUri); 132 | mFileUri = null; 133 | } catch (FileNotFoundException e) { 134 | Toast.makeText(this, "删除文件失败!", Toast.LENGTH_SHORT).show(); 135 | } 136 | } else { 137 | Toast.makeText(this, "请先创建测试文件!", Toast.LENGTH_SHORT).show(); 138 | } 139 | 140 | } 141 | 142 | @Override 143 | public void onActivityResult(int requestCode, int resultCode, Intent resultData) { 144 | super.onActivityResult(requestCode, resultCode, resultData); 145 | if (resultCode == Activity.RESULT_OK) { 146 | if (resultData != null) { 147 | //获取Uri 148 | Uri uri = resultData.getData(); 149 | if (requestCode == READ_REQUEST_CODE) { 150 | getImageMetaData(uri); 151 | showImage(uri); 152 | } else if (requestCode == WRITE_REQUEST_CODE) { 153 | mFileUri = uri; 154 | Toast.makeText(this, "文件创建完成,请在文件管理器自行查看。", Toast.LENGTH_SHORT).show(); 155 | } else if (requestCode == REQUEST_CODE_FOR_DIR) { 156 | if (uri != null) { 157 | // 保存获取的目录权限 158 | final int takeFlags = resultData.getFlags() 159 | & (Intent.FLAG_GRANT_READ_URI_PERMISSION 160 | | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 161 | getContentResolver().takePersistableUriPermission(uri, takeFlags); 162 | 163 | // 保存uri 164 | SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE); 165 | SharedPreferences.Editor editor = sp.edit(); 166 | editor.putString("uriTree", uri.toString()); 167 | editor.apply(); 168 | 169 | getDirInfo(uri); 170 | } 171 | } 172 | } 173 | } 174 | } 175 | 176 | public void getDirPermission(View view) { 177 | SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE); 178 | String uriTree = sp.getString("uriTree", ""); 179 | if (TextUtils.isEmpty(uriTree)) { 180 | startSafForDirPermission(); 181 | } else { 182 | try { 183 | Uri uri = Uri.parse(uriTree); 184 | final int takeFlags = getIntent().getFlags() 185 | & (Intent.FLAG_GRANT_READ_URI_PERMISSION 186 | | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); 187 | getContentResolver().takePersistableUriPermission(uri, takeFlags); 188 | 189 | getDirInfo(uri); 190 | 191 | } catch (SecurityException e) { 192 | mInfo.setText(""); 193 | startSafForDirPermission(); 194 | } 195 | } 196 | } 197 | 198 | public void releasePermission(View view) { 199 | 200 | SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE); 201 | String uriTree = sp.getString("uriTree", ""); 202 | 203 | if (!TextUtils.isEmpty(uriTree)) { 204 | try { 205 | Uri uri = Uri.parse(uriTree); 206 | final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; 207 | 208 | getContentResolver().releasePersistableUriPermission(uri, takeFlags); 209 | // 或者 210 | this.revokeUriPermission(uri, takeFlags); 211 | // 重启才会生效,所以可以清除uriTree 212 | SharedPreferences.Editor editor = sp.edit(); 213 | editor.putString("uriTree", ""); 214 | editor.apply(); 215 | } catch (SecurityException e) { 216 | e.fillInStackTrace(); 217 | } 218 | } 219 | } 220 | 221 | private void getDirInfo(Uri uri) { 222 | // 创建所选目录的DocumentFile,可以使用它进行文件操作 223 | DocumentFile root = DocumentFile.fromTreeUri(this, uri); 224 | mInfo.setText("Uri: " + root.getUri() + "\n" + 225 | "Name: " + root.getName()); 226 | } 227 | 228 | private void startSafForDirPermission() { 229 | // 用户可以选择任意文件夹,将它及其子文件夹的读写权限授予APP。 230 | // Android 11限制:无法授权访问存储根目录、Download文件夹。 231 | Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); 232 | startActivityForResult(intent, REQUEST_CODE_FOR_DIR); 233 | } 234 | 235 | private void showImage(Uri uri) { 236 | image.setImageURI(uri); 237 | } 238 | 239 | public void getImageMetaData(Uri uri) { 240 | Cursor cursor = this.getContentResolver() 241 | .query(uri, IMAGE_PROJECTION, null, null, null, null); 242 | 243 | if (cursor != null && cursor.moveToFirst()) { 244 | String displayName = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[0])); 245 | String size = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[1])); 246 | String id = cursor.getString(cursor.getColumnIndexOrThrow(IMAGE_PROJECTION[2])); 247 | 248 | mInfo.setText( 249 | "Uri: " + uri.toString() + "\n" + 250 | "Name: " + displayName + "\n" + 251 | "Size: " + size + "Byte"); 252 | } 253 | cursor.close(); 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | 19 | 22 | 25 | 26 | 27 | 28 | 34 | 35 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 14 | 15 | 19 | 20 | 25 | 26 |