├── .gitignore ├── .idea ├── .gitignore ├── .name ├── compiler.xml ├── gradle.xml ├── misc.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── dede │ │ └── mediastoredemo │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── assets │ │ └── wallhaven_rdyyjm.jpg │ ├── java │ │ └── com │ │ │ └── dede │ │ │ └── mediastoredemo │ │ │ ├── ActivityResultLauncherCompat.kt │ │ │ ├── ImageExt.kt │ │ │ └── MainActivity.kt │ └── 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.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-night │ │ └── themes.xml │ │ └── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ └── test │ └── java │ └── com │ └── dede │ └── mediastoredemo │ └── ExampleUnitTest.kt ├── 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/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | ImageExt.kt -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 19 | 20 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MediaStoreDemo 2 | 3 | # 支持Android 12,全版本保存图片到相册方案 4 | 5 | ## 背景 6 | 由于Google对用户隐私和系统安全做的越来越完善,应用对一些敏感信息的操作越来越难。比如最常见的共享存储空间的访问,像保存图片到相册这种常见的需求。 7 | 8 | * `Android 6.0` 以前,应用要想保存图片到相册,只需要通过`File`对象打开IO流就可以保存; 9 | * `Android 6.0` 添加了运行时权限,需要先申请存储权限才可以保存图片; 10 | * `Android 10` 引入了分区存储,但不是强制的,可以通过清单配置`android:requestLegacyExternalStorage="true"`关闭分区存储; 11 | * `Android 11` 强制开启分区存储,应用以 Android 11 为目标版本,系统会忽略 `requestLegacyExternalStorage`标记,访问共享存储空间都需要使用`MediaStore`进行访问。 12 | 13 | 我们通过上面的时间线可以看出,Google对系统公共存储的访问的门槛逐渐升高,摒弃传统的Java File对象直接访问文件的方式,想将Android的共享空间访问方式统一成一套API。这是我们的主角`MediaStore` 14 | 15 | `MediaStore` 是Android诞生之初就存在的一套媒体库框架,通过[文档](https://developer.android.google.cn/reference/android/provider/MediaStore)可以看到`Added in API level 1`。但是由于最初系统比较开放,我们对它的使用并不多,但是随着分区存储的开启,它的舞台会越来越多。 16 | 17 | 所以怎么才是正确的保存图片的方案呢?话不多说,步入正题 18 | 19 | ## 大致流程 20 | 21 | 我们访问`MediaStore`有点像访问数据库,实际上就是数据库,只是多了一些IO流的操作。将图片想象成数据库中的一条数据,我们怎么插入数据库呢,回想sqlite怎么操作的。 22 | 23 | 实际上`Mediastore`也是这样的: 24 | 1. 先将图片记录插入媒体库,获得插入的Uri; 25 | 2. 然后通过插入Uri打开输出流将文件写入; 26 | 27 | 大致流程就是这样子,只是不同的系统版本有一些细微的差距; 28 | 29 | * Android 10 之前的版本需要申请存储权限,**Android 10及以后版本是不需要读写权限的** 30 | * Android 10 之前是通过File路径打开流的,所以需要判断文件是否已经存在,否者的话会将以存在的图片给覆盖 31 | * Android 10 及以后版本添加了`IS_PENDING`状态标识,为0时其他应用才可见,所以在图片保存过后需要更新这个标识。 32 | 33 | 相信说了这么多,大家已经不耐烦了,不慌代码马上就来。 34 | 35 | ## 编码时间 36 | 37 | 这里用保存Bitmap到图库为例,保存文件 和 权限申请的逻辑,这里就不贴代码了,详见 [Demo](https://github.com/hushenghao/MediaStoreDemo.git) 38 | 39 | 检查清单文件,如果应用里没有其他需要存储权限的需求可以加上`android:maxSdkVersion="28"`,这样Android 10的设备的应用详情就看不到这个权限了。 40 | ```xml 41 | 42 | 45 | 48 | ``` 49 | 保存图片到相册。这里为了演示方便,生产环境记得在IO线程处理,ANR了可不怪我。 50 | ```kotlin 51 | private fun saveImageInternal() { 52 | val uri = assets.open("wallhaven_rdyyjm.jpg").use { 53 | it.saveToAlbum(this, fileName = "save_wallhaven_rdyyjm.jpg", null) 54 | } ?: return 55 | 56 | Toast.makeText(this, uri.toString(), Toast.LENGTH_SHORT).show() 57 | } 58 | ``` 59 | 60 | 是不是很简单,详细实现是怎么弄的,接着往下看。这是一个保存Bitmap的扩展方法 61 | ```kotlin 62 | /** 63 | * 保存Bitmap到相册的Pictures文件夹 64 | * 65 | * @param context 上下文 66 | * @param fileName 文件名。 需要携带后缀 67 | * @param relativePath 相对于Pictures的路径 68 | * @param quality 质量 69 | */ 70 | fun Bitmap.saveToAlbum( 71 | context: Context, 72 | fileName: String, 73 | relativePath: String? = null, 74 | quality: Int = 75 75 | ): Uri? { 76 | val resolver = context.contentResolver 77 | val outputFile = OutputFileTaker() 78 | // 插入图片信息 79 | val imageUri = resolver.insertMediaImage(fileName, relativePath, outputFile) 80 | if (imageUri == null) { 81 | Log.w(TAG, "insert: error: uri == null") 82 | return null 83 | } 84 | 85 | // 通过Uri打开输出流 86 | (imageUri.outputStream(resolver) ?: return null).use { 87 | val format = 88 | if (fileName.endsWith(".png")) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG 89 | // 保存图片 90 | this@saveToAlbum.compress(format, quality, it) 91 | // 更新 IS_PENDING 状态 92 | imageUri.finishPending(context, resolver, outputFile.file) 93 | } 94 | return imageUri 95 | } 96 | ``` 97 | 98 | 插入图片到媒体库,需要注意Android 10以下需要图片查重,防止文件被覆盖的问题。 99 | ```kotlin 100 | const val MIME_PNG = "image/png" 101 | const val MIME_JPG = "image/jpg" 102 | // 保存位置,这里使用Picures,也可以改为 DCIM 103 | private val ALBUM_DIR = Environment.DIRECTORY_PICTURES 104 | 105 | /** 106 | * 用于Q以下系统获取图片文件大小来更新[MediaStore.Images.Media.SIZE] 107 | */ 108 | private class OutputFileTaker(var file: File? = null) 109 | 110 | /** 111 | * 插入图片到媒体库 112 | */ 113 | private fun ContentResolver.insertMediaImage( 114 | fileName: String, 115 | relativePath: String?, 116 | outputFileTaker: OutputFileTaker? = null 117 | ): Uri? { 118 | // 图片信息 119 | val imageValues = ContentValues().apply { 120 | val mimeType = if (fileName.endsWith(".png")) MIME_PNG else MIME_JPG 121 | put(MediaStore.Images.Media.MIME_TYPE, mimeType) 122 | // 插入时间 123 | val date = System.currentTimeMillis() / 1000 124 | put(MediaStore.Images.Media.DATE_ADDED, date) 125 | put(MediaStore.Images.Media.DATE_MODIFIED, date) 126 | } 127 | // 保存的位置 128 | val collection: Uri 129 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 130 | val path = if (relativePath != null) "${ALBUM_DIR}/${relativePath}" else ALBUM_DIR 131 | imageValues.apply { 132 | put(MediaStore.Images.Media.DISPLAY_NAME, fileName) 133 | put(MediaStore.Images.Media.RELATIVE_PATH, path) 134 | put(MediaStore.Images.Media.IS_PENDING, 1) 135 | } 136 | collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) 137 | // 高版本不用查重直接插入,会自动重命名 138 | } else { 139 | // 老版本 140 | val pictures = Environment.getExternalStoragePublicDirectory(ALBUM_DIR) 141 | val saveDir = if (relativePath != null) File(pictures, relativePath) else pictures 142 | 143 | if (!saveDir.exists() && !saveDir.mkdirs()) { 144 | Log.e(TAG, "save: error: can't create Pictures directory") 145 | return null 146 | } 147 | 148 | // 文件路径查重,重复的话在文件名后拼接数字 149 | var imageFile = File(saveDir, fileName) 150 | val fileNameWithoutExtension = imageFile.nameWithoutExtension 151 | val fileExtension = imageFile.extension 152 | 153 | // 查询文件是否已经存在 154 | var queryUri = this.queryMediaImage28(imageFile.absolutePath) 155 | var suffix = 1 156 | while (queryUri != null) { 157 | // 存在的话重命名,路径后面拼接 fileNameWithoutExtension(数字).png 158 | val newName = fileNameWithoutExtension + "(${suffix++})." + fileExtension 159 | imageFile = File(saveDir, newName) 160 | queryUri = this.queryMediaImage28(imageFile.absolutePath) 161 | } 162 | 163 | imageValues.apply { 164 | put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.name) 165 | // 保存路径 166 | val imagePath = imageFile.absolutePath 167 | Log.v(TAG, "save file: $imagePath") 168 | put(MediaStore.Images.Media.DATA, imagePath) 169 | } 170 | outputFileTaker?.file = imageFile// 回传文件路径,用于设置文件大小 171 | collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 172 | } 173 | // 插入图片信息 174 | return this.insert(collection, imageValues) 175 | } 176 | 177 | /** 178 | * Android Q以下版本,查询媒体库中当前路径是否存在 179 | * @return Uri 返回null时说明不存在,可以进行图片插入逻辑 180 | */ 181 | private fun ContentResolver.queryMediaImage28(imagePath: String): Uri? { 182 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return null 183 | 184 | val imageFile = File(imagePath) 185 | if (imageFile.canRead() && imageFile.exists()) { 186 | Log.v(TAG, "query: path: $imagePath exists") 187 | // 文件已存在,返回一个file://xxx的uri 188 | // 这个逻辑也可以不要,但是为了减少媒体库查询次数,可以直接判断文件是否存在 189 | return Uri.fromFile(imageFile) 190 | } 191 | // 保存的位置 192 | val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 193 | 194 | // 查询是否已经存在相同图片 195 | val query = this.query( 196 | collection, 197 | arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA), 198 | "${MediaStore.Images.Media.DATA} == ?", 199 | arrayOf(imagePath), null 200 | ) 201 | query?.use { 202 | while (it.moveToNext()) { 203 | val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID) 204 | val id = it.getLong(idColumn) 205 | val existsUri = ContentUris.withAppendedId(collection, id) 206 | Log.v(TAG, "query: path: $imagePath exists uri: $existsUri") 207 | return existsUri 208 | } 209 | } 210 | return null 211 | } 212 | ``` 213 | 改变标志位,通知媒体库我完事了,到这里整个图片保存就结束了。怎么样是不是很简单,赶紧去系统图库里看看图片是不是已经在了。 214 | ```kotlin 215 | private fun Uri.finishPending( 216 | context: Context, 217 | resolver: ContentResolver, 218 | outputFile: File? 219 | ) { 220 | val imageValues = ContentValues() 221 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { 222 | if (outputFile != null) { 223 | // Android 10 以下需要更新文件大小字段,否则部分设备的图库里照片大小显示为0 224 | imageValues.put(MediaStore.Images.Media.SIZE, outputFile.length()) 225 | } 226 | resolver.update(this, imageValues, null, null) 227 | // 通知媒体库更新,部分设备不更新 图库看不到 ??? 228 | val intent = Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, this) 229 | context.sendBroadcast(intent) 230 | } else { 231 | // Android Q添加了IS_PENDING状态,为0时其他应用才可见 232 | imageValues.put(MediaStore.Images.Media.IS_PENDING, 0) 233 | resolver.update(this, imageValues, null, null) 234 | } 235 | } 236 | ``` 237 | 虽然代码有点多,但是相信**大家期盼已久了** [ImageExt.kt](https://raw.githubusercontent.com/hushenghao/MediaStoreDemo/main/app/src/main/java/com/dede/mediastoredemo/ImageExt.kt) 238 | 239 | ## 图片分享 240 | 241 | 有很多场景是保存图片之后,调用第三方分享进行图片分享,但是一些文章不管三七二十一说需要用`FileProvider`。实际上这是不准确的,部分情况是需要,还有一些场景是不需要的。 242 | 243 | 我们只需要记得 **FileProvider是给其他应用分享应用私有文件的** 就够了,只有在我们需要将应用沙盒内的文件共享出去的时候才需要配置FileProvider。例如: 244 | 245 | * 应用内更新,系统包安装器需要读取系统沙盒内的apk文件(如果你下载了公共路径那另说) 246 | * 应用内沙盒图片分享,微信已经要求一定要通过FileProvider才可以分享图片了(没有适配的赶紧看看分享还能用吗) 247 | 248 | 但是保存到系统图库并分享的场景明显就不符合这个场景,因为图库不是应用私有的空间。 249 | 250 | ```kotlin 251 | private fun shareImageInternal() { 252 | val uri = assets.open("wallhaven_rdyyjm.jpg").use { 253 | it.saveToAlbum(this, fileName = "save_wallhaven_rdyyjm.jpg", null) 254 | } ?: return 255 | val intent = Intent(Intent.ACTION_SEND) 256 | .putExtra(Intent.EXTRA_STREAM, uri) 257 | .setType("image/*") 258 | startActivity(Intent.createChooser(intent, null)) 259 | } 260 | ``` 261 | 262 | 所以在使用FileProvider要区分一下场景,是不是可以不需要,因为FileProvider是一种特殊的ContentProvider,每一个内容提供者在应用启动的时候都要初始化,所以也会拖慢应用的启动速度。 263 | 264 | ## 参考资料 265 | [Demo](https://github.com/hushenghao/MediaStoreDemo.git) 266 | 267 | [访问共享存储空间中的媒体文件](https://developer.android.google.cn/training/data-storage/shared/media) 268 | 269 | [Android MediaStore](https://developer.android.google.cn/reference/android/provider/MediaStore) 270 | 271 | [OpenSDK支持FileProvider方式分享文件到微信]( 272 | https://developers.weixin.qq.com/community/develop/doc/0004886026c1a8402d2a040ee5b401) 273 | 274 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.android.application' 3 | id 'kotlin-android' 4 | } 5 | 6 | android { 7 | compileSdk 31 8 | 9 | defaultConfig { 10 | applicationId "com.dede.mediastoredemo" 11 | minSdk 19 12 | targetSdk 31 13 | versionCode 1 14 | versionName "1.0" 15 | 16 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 17 | } 18 | 19 | buildTypes { 20 | release { 21 | minifyEnabled false 22 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 23 | } 24 | } 25 | compileOptions { 26 | sourceCompatibility JavaVersion.VERSION_1_8 27 | targetCompatibility JavaVersion.VERSION_1_8 28 | } 29 | kotlinOptions { 30 | jvmTarget = '1.8' 31 | } 32 | } 33 | 34 | dependencies { 35 | implementation 'androidx.core:core-ktx:1.6.0' 36 | implementation 'androidx.appcompat:appcompat:1.3.1' 37 | implementation 'com.google.android.material:material:1.3.0' 38 | implementation 'androidx.constraintlayout:constraintlayout:2.0.4' 39 | testImplementation 'junit:junit:4.+' 40 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 41 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 42 | } -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /app/src/androidTest/java/com/dede/mediastoredemo/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.dede.mediastoredemo 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.dede.mediastoredemo", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 9 | 12 | 13 | 20 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /app/src/main/assets/wallhaven_rdyyjm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hushenghao/MediaStoreDemo/f6f000256de3338c1f481c0a5c8ceb9a75253e9d/app/src/main/assets/wallhaven_rdyyjm.jpg -------------------------------------------------------------------------------- /app/src/main/java/com/dede/mediastoredemo/ActivityResultLauncherCompat.kt: -------------------------------------------------------------------------------- 1 | package com.dede.mediastoredemo 2 | 3 | import androidx.activity.result.ActivityResultCallback 4 | import androidx.activity.result.ActivityResultCaller 5 | import androidx.activity.result.ActivityResultLauncher 6 | import androidx.activity.result.ActivityResultRegistry 7 | import androidx.activity.result.contract.ActivityResultContract 8 | import androidx.core.app.ActivityOptionsCompat 9 | import androidx.fragment.app.Fragment 10 | import androidx.fragment.app.FragmentActivity 11 | import androidx.lifecycle.Lifecycle 12 | import androidx.lifecycle.LifecycleObserver 13 | import androidx.lifecycle.LifecycleOwner 14 | import androidx.lifecycle.OnLifecycleEvent 15 | 16 | /** 17 | * ActivityResultLauncher ext 18 | * 19 | * 对ActivityResult的封装,解决了api不够友好的问题。 20 | * 由于实现机制问题,导致状态恢复时无法回调。(依赖不可见Fragment实现的权限框架同样的问题) 21 | * 22 | * @author hsh 23 | * @since 2021/11/9 3:19 下午 24 | */ 25 | class ActivityResultLauncherCompat constructor( 26 | private val caller: ActivityResultCaller, 27 | private val contract: ActivityResultContract, 28 | private val registry: ActivityResultRegistry?, 29 | private val lifecycleOwner: LifecycleOwner 30 | ) : LifecycleObserver, ActivityResultCallback { 31 | 32 | private var activityResultLauncher: ActivityResultLauncher? = null 33 | private var activityResultCallback: ActivityResultCallback? = null 34 | 35 | constructor( 36 | caller: ActivityResultCaller, 37 | contract: ActivityResultContract, 38 | lifecycleOwner: LifecycleOwner 39 | ) : this(caller, contract, null, lifecycleOwner) 40 | 41 | constructor(fragment: Fragment, contract: ActivityResultContract) : 42 | this(fragment, contract, fragment) 43 | 44 | constructor(activity: FragmentActivity, contract: ActivityResultContract) : 45 | this(activity, contract, activity) 46 | 47 | init { 48 | lifecycleOwner.lifecycle.addObserver(this) 49 | } 50 | 51 | @OnLifecycleEvent(value = Lifecycle.Event.ON_CREATE) 52 | fun onCreate(owner: LifecycleOwner) { 53 | activityResultLauncher = if (registry == null) { 54 | caller.registerForActivityResult(contract, this) 55 | } else { 56 | caller.registerForActivityResult(contract, registry, this) 57 | } 58 | } 59 | 60 | @OnLifecycleEvent(value = Lifecycle.Event.ON_START) 61 | fun onStart(owner: LifecycleOwner) { 62 | if (activityResultLauncher == null) { 63 | throw IllegalStateException("ActivityResultLauncherCompat must initialize before they are STARTED.") 64 | } 65 | } 66 | 67 | @OnLifecycleEvent(value = Lifecycle.Event.ON_DESTROY) 68 | fun onDestroy(owner: LifecycleOwner) { 69 | lifecycleOwner.lifecycle.removeObserver(this) 70 | } 71 | 72 | override fun onActivityResult(result: O) { 73 | // 由于不是在onCreate里设置的回调,状态恢复时callback还没有复制 74 | activityResultCallback?.onActivityResult(result) 75 | } 76 | 77 | fun launch(input: I, callback: ActivityResultCallback) { 78 | launch(input, null, callback) 79 | } 80 | 81 | fun launch(input: I, options: ActivityOptionsCompat?, callback: ActivityResultCallback) { 82 | activityResultCallback = callback 83 | activityResultLauncher?.launch(input, options) 84 | } 85 | 86 | } -------------------------------------------------------------------------------- /app/src/main/java/com/dede/mediastoredemo/ImageExt.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("ImageExt") 2 | @file:Suppress("unused") 3 | 4 | package com.dede.mediastoredemo 5 | 6 | import android.content.* 7 | import android.graphics.Bitmap 8 | import android.net.Uri 9 | import android.os.Build 10 | import android.os.Environment 11 | import android.provider.MediaStore 12 | import android.util.Log 13 | import java.io.File 14 | import java.io.FileNotFoundException 15 | import java.io.InputStream 16 | import java.io.OutputStream 17 | 18 | 19 | private const val TAG = "ImageExt" 20 | 21 | private val ALBUM_DIR = Environment.DIRECTORY_PICTURES 22 | 23 | private class OutputFileTaker(var file: File? = null) 24 | 25 | /** 26 | * 复制图片文件到相册的Pictures文件夹 27 | * 28 | * @param context 上下文 29 | * @param fileName 文件名。 需要携带后缀 30 | * @param relativePath 相对于Pictures的路径 31 | */ 32 | fun File.copyToAlbum(context: Context, fileName: String, relativePath: String?): Uri? { 33 | if (!this.canRead() || !this.exists()) { 34 | Log.w(TAG, "check: read file error: $this") 35 | return null 36 | } 37 | return this.inputStream().use { 38 | it.saveToAlbum(context, fileName, relativePath) 39 | } 40 | } 41 | 42 | /** 43 | * 保存图片Stream到相册的Pictures文件夹 44 | * 45 | * @param context 上下文 46 | * @param fileName 文件名。 需要携带后缀 47 | * @param relativePath 相对于Pictures的路径 48 | */ 49 | fun InputStream.saveToAlbum(context: Context, fileName: String, relativePath: String?): Uri? { 50 | val resolver = context.contentResolver 51 | val outputFile = OutputFileTaker() 52 | val imageUri = resolver.insertMediaImage(fileName, relativePath, outputFile) 53 | if (imageUri == null) { 54 | Log.w(TAG, "insert: error: uri == null") 55 | return null 56 | } 57 | 58 | (imageUri.outputStream(resolver) ?: return null).use { output -> 59 | this.use { input -> 60 | input.copyTo(output) 61 | imageUri.finishPending(context, resolver, outputFile.file) 62 | } 63 | } 64 | return imageUri 65 | } 66 | 67 | /** 68 | * 保存Bitmap到相册的Pictures文件夹 69 | * 70 | * https://developer.android.google.cn/training/data-storage/shared/media 71 | * 72 | * @param context 上下文 73 | * @param fileName 文件名。 需要携带后缀 74 | * @param relativePath 相对于Pictures的路径 75 | * @param quality 质量 76 | */ 77 | fun Bitmap.saveToAlbum( 78 | context: Context, 79 | fileName: String, 80 | relativePath: String? = null, 81 | quality: Int = 75, 82 | ): Uri? { 83 | // 插入图片信息 84 | val resolver = context.contentResolver 85 | val outputFile = OutputFileTaker() 86 | val imageUri = resolver.insertMediaImage(fileName, relativePath, outputFile) 87 | if (imageUri == null) { 88 | Log.w(TAG, "insert: error: uri == null") 89 | return null 90 | } 91 | 92 | // 保存图片 93 | (imageUri.outputStream(resolver) ?: return null).use { 94 | val format = fileName.getBitmapFormat() 95 | this@saveToAlbum.compress(format, quality, it) 96 | imageUri.finishPending(context, resolver, outputFile.file) 97 | } 98 | return imageUri 99 | } 100 | 101 | private fun Uri.outputStream(resolver: ContentResolver): OutputStream? { 102 | return try { 103 | resolver.openOutputStream(this) 104 | } catch (e: FileNotFoundException) { 105 | Log.e(TAG, "save: open stream error: $e") 106 | null 107 | } 108 | } 109 | 110 | private fun Uri.finishPending( 111 | context: Context, 112 | resolver: ContentResolver, 113 | outputFile: File?, 114 | ) { 115 | val imageValues = ContentValues() 116 | if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { 117 | if (outputFile != null) { 118 | imageValues.put(MediaStore.Images.Media.SIZE, outputFile.length()) 119 | } 120 | resolver.update(this, imageValues, null, null) 121 | // 通知媒体库更新 122 | val intent = Intent(@Suppress("DEPRECATION") Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, this) 123 | context.sendBroadcast(intent) 124 | } else { 125 | // Android Q添加了IS_PENDING状态,为0时其他应用才可见 126 | imageValues.put(MediaStore.Images.Media.IS_PENDING, 0) 127 | resolver.update(this, imageValues, null, null) 128 | } 129 | } 130 | 131 | private fun String.getBitmapFormat(): Bitmap.CompressFormat { 132 | val fileName = this.lowercase() 133 | return when { 134 | fileName.endsWith(".png") -> Bitmap.CompressFormat.PNG 135 | fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> Bitmap.CompressFormat.JPEG 136 | fileName.endsWith(".webp") -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) 137 | Bitmap.CompressFormat.WEBP_LOSSLESS else Bitmap.CompressFormat.WEBP 138 | else -> Bitmap.CompressFormat.PNG 139 | } 140 | } 141 | 142 | private fun String.getMimeType(): String? { 143 | val fileName = this.lowercase() 144 | return when { 145 | fileName.endsWith(".png") -> "image/png" 146 | fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") -> "image/jpeg" 147 | fileName.endsWith(".webp") -> "image/webp" 148 | fileName.endsWith(".gif") -> "image/gif" 149 | else -> null 150 | } 151 | } 152 | 153 | /** 154 | * 插入图片到媒体库 155 | */ 156 | private fun ContentResolver.insertMediaImage( 157 | fileName: String, 158 | relativePath: String?, 159 | outputFileTaker: OutputFileTaker? = null, 160 | ): Uri? { 161 | // 图片信息 162 | val imageValues = ContentValues().apply { 163 | val mimeType = fileName.getMimeType() 164 | if (mimeType != null) { 165 | put(MediaStore.Images.Media.MIME_TYPE, mimeType) 166 | } 167 | val date = System.currentTimeMillis() / 1000 168 | put(MediaStore.Images.Media.DATE_ADDED, date) 169 | put(MediaStore.Images.Media.DATE_MODIFIED, date) 170 | } 171 | // 保存的位置 172 | val collection: Uri 173 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 174 | val path = if (relativePath != null) "${ALBUM_DIR}/${relativePath}" else ALBUM_DIR 175 | imageValues.apply { 176 | put(MediaStore.Images.Media.DISPLAY_NAME, fileName) 177 | put(MediaStore.Images.Media.RELATIVE_PATH, path) 178 | put(MediaStore.Images.Media.IS_PENDING, 1) 179 | } 180 | collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) 181 | // 高版本不用查重直接插入,会自动重命名 182 | } else { 183 | // 老版本 184 | val pictures = 185 | @Suppress("DEPRECATION") Environment.getExternalStoragePublicDirectory(ALBUM_DIR) 186 | val saveDir = if (relativePath != null) File(pictures, relativePath) else pictures 187 | 188 | if (!saveDir.exists() && !saveDir.mkdirs()) { 189 | Log.e(TAG, "save: error: can't create Pictures directory") 190 | return null 191 | } 192 | 193 | // 文件路径查重,重复的话在文件名后拼接数字 194 | var imageFile = File(saveDir, fileName) 195 | val fileNameWithoutExtension = imageFile.nameWithoutExtension 196 | val fileExtension = imageFile.extension 197 | 198 | var queryUri = this.queryMediaImage28(imageFile.absolutePath) 199 | var suffix = 1 200 | while (queryUri != null) { 201 | val newName = fileNameWithoutExtension + "(${suffix++})." + fileExtension 202 | imageFile = File(saveDir, newName) 203 | queryUri = this.queryMediaImage28(imageFile.absolutePath) 204 | } 205 | 206 | imageValues.apply { 207 | put(MediaStore.Images.Media.DISPLAY_NAME, imageFile.name) 208 | // 保存路径 209 | val imagePath = imageFile.absolutePath 210 | Log.v(TAG, "save file: $imagePath") 211 | put(@Suppress("DEPRECATION") MediaStore.Images.Media.DATA, imagePath) 212 | } 213 | outputFileTaker?.file = imageFile// 回传文件路径,用于设置文件大小 214 | collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 215 | } 216 | // 插入图片信息 217 | return this.insert(collection, imageValues) 218 | } 219 | 220 | /** 221 | * Android Q以下版本,查询媒体库中当前路径是否存在 222 | * @return Uri 返回null时说明不存在,可以进行图片插入逻辑 223 | */ 224 | private fun ContentResolver.queryMediaImage28(imagePath: String): Uri? { 225 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) return null 226 | 227 | val imageFile = File(imagePath) 228 | if (imageFile.canRead() && imageFile.exists()) { 229 | Log.v(TAG, "query: path: $imagePath exists") 230 | // 文件已存在,返回一个file://xxx的uri 231 | return Uri.fromFile(imageFile) 232 | } 233 | // 保存的位置 234 | val collection = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 235 | 236 | // 查询是否已经存在相同图片 237 | val query = this.query( 238 | collection, 239 | arrayOf(MediaStore.Images.Media._ID, @Suppress("DEPRECATION") MediaStore.Images.Media.DATA), 240 | "${@Suppress("DEPRECATION") MediaStore.Images.Media.DATA} == ?", 241 | arrayOf(imagePath), null 242 | ) 243 | query?.use { 244 | while (it.moveToNext()) { 245 | val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID) 246 | val id = it.getLong(idColumn) 247 | val existsUri = ContentUris.withAppendedId(collection, id) 248 | Log.v(TAG, "query: path: $imagePath exists uri: $existsUri") 249 | return existsUri 250 | } 251 | } 252 | return null 253 | } 254 | -------------------------------------------------------------------------------- /app/src/main/java/com/dede/mediastoredemo/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.dede.mediastoredemo 2 | 3 | import android.Manifest 4 | import android.content.Intent 5 | import android.graphics.BitmapFactory 6 | import android.os.Build 7 | import android.os.Bundle 8 | import android.view.View 9 | import android.widget.ImageView 10 | import android.widget.Toast 11 | import androidx.activity.result.contract.ActivityResultContracts 12 | import androidx.appcompat.app.AppCompatActivity 13 | 14 | class MainActivity : AppCompatActivity() { 15 | 16 | private val launcherCompat = 17 | ActivityResultLauncherCompat(this, ActivityResultContracts.RequestMultiplePermissions()) 18 | 19 | override fun onCreate(savedInstanceState: Bundle?) { 20 | super.onCreate(savedInstanceState) 21 | setContentView(R.layout.activity_main) 22 | val image = findViewById(R.id.image) 23 | 24 | val options = BitmapFactory.Options() 25 | options.inSampleSize = 2 26 | val stream = assets.open("wallhaven_rdyyjm.jpg") 27 | val bitmap = BitmapFactory.decodeStream(stream, null, options) 28 | image.setImageBitmap(bitmap) 29 | } 30 | 31 | private fun saveImageInternal() { 32 | val uri = assets.open("wallhaven_rdyyjm.jpg").use { 33 | it.saveToAlbum(this, fileName = "save_wallhaven_rdyyjm.jpg", null) 34 | } ?: return 35 | 36 | Toast.makeText(this, uri.toString(), Toast.LENGTH_SHORT).show() 37 | } 38 | 39 | fun saveImage(view: View) { 40 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 41 | saveImageInternal() 42 | } else { 43 | val permissions = arrayOf( 44 | Manifest.permission.READ_EXTERNAL_STORAGE, 45 | Manifest.permission.WRITE_EXTERNAL_STORAGE 46 | ) 47 | launcherCompat.launch(permissions) { 48 | saveImageInternal() 49 | } 50 | } 51 | } 52 | 53 | private fun shareImageInternal() { 54 | val uri = assets.open("wallhaven_rdyyjm.jpg").use { 55 | it.saveToAlbum(this, fileName = "save_wallhaven_rdyyjm.jpg", null) 56 | } ?: return 57 | val intent = Intent(Intent.ACTION_SEND) 58 | .putExtra(Intent.EXTRA_STREAM, uri) 59 | .setType("image/*") 60 | startActivity(Intent.createChooser(intent, null)) 61 | } 62 | 63 | fun shareImage(view: View) { 64 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 65 | shareImageInternal() 66 | } else { 67 | val permissions = arrayOf( 68 | Manifest.permission.READ_EXTERNAL_STORAGE, 69 | Manifest.permission.WRITE_EXTERNAL_STORAGE 70 | ) 71 | launcherCompat.launch(permissions) { 72 | shareImageInternal() 73 | } 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /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 | 8 | 9 | 15 | 16 |