Skip to content
This repository was archived by the owner on Aug 29, 2025. It is now read-only.

Commit ced5db8

Browse files
committed
Add AddDocument in MediaStore logic
1 parent b2e8c5a commit ced5db8

4 files changed

Lines changed: 507 additions & 4 deletions

File tree

ScopedStorage/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
1919
package="com.samples.storage">
2020

21-
<!-- Use to download images in the [AddMediaFragment] demo -->
21+
<!-- Use to download content in the MediaStore demo -->
2222
<uses-permission android:name="android.permission.INTERNET" />
2323

2424
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
Lines changed: 385 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,385 @@
1+
/*
2+
* Copyright 2021 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.samples.storage.mediastore
17+
18+
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
19+
import android.app.Application
20+
import android.content.ContentValues
21+
import android.content.Context
22+
import android.content.pm.PackageManager
23+
import android.media.MediaScannerConnection
24+
import android.net.Uri
25+
import android.os.Build
26+
import android.os.Bundle
27+
import android.os.Environment
28+
import android.os.Environment.DIRECTORY_DOWNLOADS
29+
import android.provider.MediaStore
30+
import android.provider.MediaStore.Files.FileColumns
31+
import android.util.Log
32+
import androidx.annotation.RequiresApi
33+
import androidx.core.content.ContextCompat
34+
import androidx.core.os.bundleOf
35+
import androidx.lifecycle.AndroidViewModel
36+
import androidx.lifecycle.SavedStateHandle
37+
import androidx.lifecycle.viewModelScope
38+
import com.samples.storage.data.SampleFiles
39+
import kotlinx.coroutines.Dispatchers
40+
import kotlinx.coroutines.launch
41+
import kotlinx.coroutines.withContext
42+
import okhttp3.OkHttpClient
43+
import okhttp3.Request
44+
import java.io.File
45+
import java.io.OutputStream
46+
import java.net.URLConnection
47+
import java.nio.file.Files
48+
import java.nio.file.attribute.FileTime
49+
50+
// TODO(yrezgui): Use the viewModel in AddDocumentFragment (all the logic is already written)
51+
// TODO(yrezgui): Create a file details property and keep it in the savedStateHandle
52+
class AddDocumentViewModel(
53+
application: Application,
54+
private val savedStateHandle: SavedStateHandle
55+
) : AndroidViewModel(application) {
56+
private val TAG = "AddDocumentViewModel"
57+
58+
private val context: Context
59+
get() = getApplication()
60+
61+
/**
62+
* Check ability to add document in the Download folder or not
63+
*/
64+
val canAddDocument: Boolean
65+
get() = canAddDocumentPermission(context)
66+
67+
/**
68+
* Using lazy to instantiate the [OkHttpClient] only when accessing it, not when the viewmodel
69+
* is created
70+
*/
71+
private val httpClient by lazy { OkHttpClient() }
72+
73+
private fun generateFilename(extension: String) = "${System.currentTimeMillis()}.$extension"
74+
75+
/**
76+
* Check if the app can writes on the shared storage
77+
*
78+
* On Android 10 (API 29), we can add files to the Downloads folder without having to request the
79+
* [WRITE_EXTERNAL_STORAGE] permission, so we only check on pre-API 29 devices
80+
*/
81+
private fun canAddDocumentPermission(context: Context): Boolean {
82+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
83+
true
84+
} else {
85+
ContextCompat.checkSelfPermission(
86+
context,
87+
WRITE_EXTERNAL_STORAGE
88+
) == PackageManager.PERMISSION_GRANTED
89+
}
90+
}
91+
92+
@Suppress("BlockingMethodInNonBlockingContext")
93+
suspend fun addRandomFile() {
94+
val randomRemoteUrl = SampleFiles.nonMedia.random()
95+
val extension = randomRemoteUrl.substring(randomRemoteUrl.lastIndexOf("."))
96+
val filename = generateFilename(extension)
97+
lateinit var outputStream: OutputStream
98+
99+
try {
100+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
101+
val uri = addFileToDownloadsApi29(filename)
102+
outputStream = context.contentResolver.openOutputStream(uri, "w")
103+
?: throw Exception("ContentResolver couldn't open $uri outputStream")
104+
105+
downloadFileFromInternet(randomRemoteUrl, outputStream)
106+
Log.d(TAG, "File downloaded ($uri)")
107+
108+
val path = getMediaStoreEntryPathApi29(uri)
109+
?: throw Exception("ContentResolver couldn't find $uri")
110+
scanFileApi(path) {
111+
Log.d(TAG, "MediaStore updated ($path)")
112+
113+
viewModelScope.launch {
114+
val fileDetails = getFileDetailsApi29(uri)
115+
Log.d(TAG, "New file: $fileDetails")
116+
}
117+
}
118+
} else {
119+
val file = addFileToDownloadsApi21(filename)
120+
outputStream = file.outputStream()
121+
122+
downloadFileFromInternet(randomRemoteUrl, outputStream)
123+
Log.d(TAG, "File downloaded (${file.absolutePath})")
124+
125+
scanFileApi(file.absolutePath) {
126+
Log.d(TAG, "MediaStore updated (${file.absolutePath})")
127+
128+
viewModelScope.launch {
129+
val fileDetails = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
130+
getFileDetailsApi26(file.absolutePath)
131+
} else {
132+
getFileDetailsApi21(file.absolutePath)
133+
}
134+
135+
Log.d(TAG, "New file: $fileDetails")
136+
}
137+
}
138+
}
139+
} catch (e: Exception) {
140+
Log.e(TAG, e.toString())
141+
}
142+
}
143+
144+
/**
145+
* Downloads a random file from internet and saves its content to the specified outputStream
146+
*/
147+
@Suppress("BlockingMethodInNonBlockingContext")
148+
private suspend fun downloadFileFromInternet(
149+
url: String,
150+
outputStream: OutputStream
151+
) {
152+
// We use OkHttp to create HTTP request
153+
val request = Request.Builder().url(url).build()
154+
155+
withContext(Dispatchers.IO) {
156+
val response = httpClient.newCall(request).execute()
157+
158+
// .use is an extension function that closes the output stream where we're
159+
// saving the file content once its lambda is finished being executed
160+
response.body?.use { responseBody ->
161+
outputStream.use {
162+
responseBody.byteStream().copyTo(it)
163+
}
164+
}
165+
}
166+
}
167+
168+
/**
169+
* Create a file inside the Download folder using java.io API
170+
*/
171+
@Suppress("BlockingMethodInNonBlockingContext")
172+
private suspend fun addFileToDownloadsApi21(filename: String): File {
173+
val downloadsFolder = Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS)
174+
175+
// Get path of the destination where the file will be saved
176+
val newNonMediaFile = File(downloadsFolder, filename)
177+
178+
return withContext(Dispatchers.IO) {
179+
// Create new file if it does not exist, throw exception otherwise
180+
if (!newNonMediaFile.createNewFile()) {
181+
throw Exception("File ${newNonMediaFile.name} already exists")
182+
}
183+
184+
return@withContext newNonMediaFile
185+
}
186+
}
187+
188+
/**
189+
* Create a file inside the Download folder using MediaStore API
190+
*/
191+
@Suppress("BlockingMethodInNonBlockingContext")
192+
@RequiresApi(Build.VERSION_CODES.Q)
193+
private suspend fun addFileToDownloadsApi29(filename: String): Uri {
194+
val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
195+
196+
return withContext(Dispatchers.IO) {
197+
val newFile = ContentValues().apply {
198+
put(MediaStore.Downloads.DISPLAY_NAME, filename)
199+
}
200+
201+
// This method will perform a binder transaction which is better to execute off the main
202+
// thread
203+
return@withContext context.contentResolver.insert(collection, newFile)
204+
?: throw Exception("MediaStore Uri couldn't be created")
205+
}
206+
}
207+
208+
/**
209+
* When adding a file (using java.io or ContentResolver APIs), MediaStore might not be aware of
210+
* the new entry or doesn't have an updated version of it. That's why some entries have 0 bytes
211+
* size, even though the file is definitely not empty. MediaStore will eventually scan the file
212+
* but it's better to do it ourselves to have a fresher state whenever we can
213+
*/
214+
private suspend fun scanFileApi(path: String, callback: () -> Unit) {
215+
withContext(Dispatchers.IO) {
216+
MediaScannerConnection.scanFile(context, arrayOf(path), emptyArray()) { path, uri ->
217+
callback()
218+
}
219+
}
220+
}
221+
222+
/**
223+
* Get a path for a MediaStore entry as it's needed when calling MediaScanner
224+
*/
225+
private suspend fun getMediaStoreEntryPathApi29(uri: Uri): String? {
226+
return withContext(Dispatchers.IO) {
227+
val cursor = context.contentResolver.query(
228+
uri,
229+
arrayOf(FileColumns.DATA),
230+
null,
231+
null,
232+
null
233+
) ?: return@withContext null
234+
235+
cursor.use {
236+
if (!cursor.moveToFirst()) {
237+
return@withContext null
238+
}
239+
240+
return@withContext cursor.getString(cursor.getColumnIndexOrThrow(FileColumns.DATA))
241+
}
242+
}
243+
}
244+
245+
/**
246+
* Get file details on Api 21
247+
*
248+
* It uses the classic java.io APIs but can't get the added time as there's not a reliable way
249+
* to do so until Api 26
250+
*/
251+
private suspend fun getFileDetailsApi21(path: String): FileEntry? {
252+
return withContext(Dispatchers.IO) {
253+
val file = File(path)
254+
255+
if (!file.exists()) {
256+
return@withContext null
257+
}
258+
259+
return@withContext FileEntry(
260+
filename = file.name,
261+
size = file.length(),
262+
mimeType = URLConnection.guessContentTypeFromName(file.name),
263+
// There are no reliable ways to get the added time on Api 26
264+
addedAt = -1,
265+
path = path
266+
)
267+
}
268+
}
269+
270+
/**
271+
* Get file details on Api 26
272+
*
273+
* It uses java.nio APIs to get the mime type and added time properties
274+
*/
275+
@Suppress("BlockingMethodInNonBlockingContext")
276+
@RequiresApi(Build.VERSION_CODES.O)
277+
private suspend fun getFileDetailsApi26(path: String): FileEntry? {
278+
return withContext(Dispatchers.IO) {
279+
val file = File(path)
280+
281+
if (!file.exists()) {
282+
return@withContext null
283+
}
284+
285+
return@withContext FileEntry(
286+
filename = file.name,
287+
size = file.length(),
288+
mimeType = Files.probeContentType(file.toPath()),
289+
addedAt = (
290+
Files.getAttribute(
291+
file.toPath(),
292+
"creationTime"
293+
) as FileTime
294+
).toMillis(),
295+
path = path
296+
)
297+
}
298+
}
299+
300+
/**
301+
* Get file details on Api 29
302+
*
303+
* It uses MediaStore to all the file properties
304+
*/
305+
private suspend fun getFileDetailsApi29(uri: Uri): FileEntry? {
306+
return withContext(Dispatchers.IO) {
307+
val cursor = context.contentResolver.query(
308+
uri,
309+
arrayOf(
310+
FileColumns.DISPLAY_NAME,
311+
FileColumns.SIZE,
312+
FileColumns.MIME_TYPE,
313+
FileColumns.DATE_ADDED,
314+
FileColumns.DATA
315+
),
316+
null,
317+
null,
318+
null
319+
) ?: return@withContext null
320+
321+
cursor.use {
322+
if (!cursor.moveToFirst()) {
323+
return@withContext null
324+
}
325+
326+
val displayNameColumn = cursor.getColumnIndexOrThrow(FileColumns.DISPLAY_NAME)
327+
val sizeColumn = cursor.getColumnIndexOrThrow(FileColumns.SIZE)
328+
val mimeTypeColumn = cursor.getColumnIndexOrThrow(FileColumns.MIME_TYPE)
329+
val dateAddedColumn = cursor.getColumnIndexOrThrow(FileColumns.DATE_ADDED)
330+
val dataColumn = cursor.getColumnIndexOrThrow(FileColumns.DATA)
331+
332+
return@withContext FileEntry(
333+
filename = cursor.getString(displayNameColumn),
334+
size = cursor.getLong(sizeColumn),
335+
mimeType = cursor.getString(mimeTypeColumn),
336+
addedAt = cursor.getLong(dateAddedColumn),
337+
path = cursor.getString(dataColumn),
338+
)
339+
}
340+
}
341+
}
342+
}
343+
344+
data class FileEntry(
345+
val filename: String,
346+
val size: Long,
347+
val mimeType: String,
348+
val addedAt: Long,
349+
val path: String
350+
) {
351+
companion object {
352+
/**
353+
* Create a [FileEntry] from a [Bundle] when loading [SavedStateHandle]
354+
*/
355+
fun fromBundle(bundle: Bundle): FileEntry? {
356+
return if (bundle.containsKey("filename") &&
357+
bundle.containsKey("size") &&
358+
bundle.containsKey("mimeType") &&
359+
bundle.containsKey("addedAt") &&
360+
bundle.containsKey("path")
361+
) {
362+
FileEntry(
363+
filename = bundle.getString("filename")!!,
364+
size = bundle.getLong("size"),
365+
mimeType = bundle.getString("mimeType")!!,
366+
addedAt = bundle.getLong("addedAt"),
367+
path = bundle.getString("path")!!
368+
)
369+
} else {
370+
null
371+
}
372+
}
373+
}
374+
375+
/**
376+
* Export [FileEntry] as a [Bundle] when saving [SavedStateHandle]
377+
*/
378+
fun toBundle() = bundleOf(
379+
"filename" to filename,
380+
"size" to size,
381+
"mimeType" to mimeType,
382+
"addedAt" to addedAt,
383+
"path" to path
384+
)
385+
}

0 commit comments

Comments
 (0)