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

Commit 22b7dda

Browse files
committed
Finish the AddDocument demo
1 parent ced5db8 commit 22b7dda

4 files changed

Lines changed: 217 additions & 67 deletions

File tree

ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentFragment.kt

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,120 @@
1515
*/
1616
package com.samples.storage.mediastore
1717

18+
import android.Manifest
1819
import android.os.Bundle
20+
import android.text.format.DateUtils
21+
import android.text.format.Formatter
1922
import android.view.LayoutInflater
2023
import android.view.View
2124
import android.view.ViewGroup
25+
import androidx.activity.result.contract.ActivityResultContracts
2226
import androidx.fragment.app.Fragment
23-
import com.samples.storage.databinding.FragmentDemoBinding
27+
import androidx.fragment.app.viewModels
28+
import androidx.lifecycle.lifecycleScope
29+
import com.samples.storage.R
30+
import com.samples.storage.databinding.FragmentAddDocumentBinding
31+
import kotlinx.coroutines.launch
2432

25-
// TODO(yrezgui): Finish this demo
2633
class AddDocumentFragment : Fragment() {
27-
private var _binding: FragmentDemoBinding? = null
34+
private var _binding: FragmentAddDocumentBinding? = null
2835
private val binding get() = _binding!!
36+
private val viewModel: AddDocumentViewModel by viewModels()
2937

3038
override fun onCreateView(
3139
inflater: LayoutInflater,
3240
container: ViewGroup?,
3341
savedInstanceState: Bundle?
3442
): View {
35-
_binding = FragmentDemoBinding.inflate(inflater, container, false)
43+
_binding = FragmentAddDocumentBinding.inflate(inflater, container, false)
44+
45+
// Every time currentFileEntry is changed, we update the file details
46+
viewModel.currentFileEntry.observe(viewLifecycleOwner) { fileDetails ->
47+
if (fileDetails == null) {
48+
binding.fileDetails.visibility = View.GONE
49+
return@observe
50+
}
51+
52+
binding.filename.text = fileDetails.filename
53+
binding.filePath.text = fileDetails.path
54+
binding.fileSizeAndMimeType.text = getString(
55+
R.string.mediastore_file_size_and_mimetype,
56+
Formatter.formatShortFileSize(context, fileDetails.size),
57+
fileDetails.mimeType
58+
)
59+
binding.fileAddedAt.text = getString(
60+
R.string.mediastore_file_added_at,
61+
DateUtils.formatDateTime(
62+
context,
63+
fileDetails.addedAt,
64+
DateUtils.FORMAT_SHOW_TIME or
65+
DateUtils.FORMAT_SHOW_DATE or
66+
DateUtils.FORMAT_SHOW_YEAR or
67+
DateUtils.FORMAT_SHOW_WEEKDAY or
68+
DateUtils.FORMAT_ABBREV_ALL
69+
)
70+
)
71+
binding.fileDetails.visibility = View.VISIBLE
72+
}
73+
74+
// Every time isDownloading is changed, we toggle the download button
75+
viewModel.isDownloading.observe(viewLifecycleOwner) { isDownloading ->
76+
binding.downloadRandomFileFromInternet.isEnabled = !isDownloading
77+
}
78+
79+
binding.requestPermissionButton.setOnClickListener {
80+
actionRequestPermission.launch(
81+
arrayOf(
82+
Manifest.permission.READ_EXTERNAL_STORAGE,
83+
Manifest.permission.WRITE_EXTERNAL_STORAGE
84+
)
85+
)
86+
}
87+
88+
binding.downloadRandomFileFromInternet.setOnClickListener {
89+
viewLifecycleOwner.lifecycleScope.launch {
90+
91+
if (viewModel.canAddDocument) {
92+
viewModel.addRandomFile()
93+
} else {
94+
showPermissionSection()
95+
}
96+
}
97+
}
98+
3699
return binding.root
37100
}
38101

39102
override fun onDestroyView() {
40103
super.onDestroyView()
41104
_binding = null
42105
}
106+
107+
override fun onResume() {
108+
super.onResume()
109+
handlePermissionSectionVisibility()
110+
}
111+
112+
private val actionRequestPermission =
113+
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
114+
handlePermissionSectionVisibility()
115+
}
116+
117+
private fun handlePermissionSectionVisibility() {
118+
if (viewModel.canAddDocument) {
119+
hidePermissionSection()
120+
} else {
121+
showPermissionSection()
122+
}
123+
}
124+
125+
private fun hidePermissionSection() {
126+
binding.permissionSection.visibility = View.GONE
127+
binding.actions.visibility = View.VISIBLE
128+
}
129+
130+
private fun showPermissionSection() {
131+
binding.permissionSection.visibility = View.VISIBLE
132+
binding.actions.visibility = View.GONE
133+
}
43134
}

ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentViewModel.kt

Lines changed: 116 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import androidx.annotation.RequiresApi
3333
import androidx.core.content.ContextCompat
3434
import androidx.core.os.bundleOf
3535
import androidx.lifecycle.AndroidViewModel
36+
import androidx.lifecycle.LiveData
37+
import androidx.lifecycle.MutableLiveData
3638
import androidx.lifecycle.SavedStateHandle
3739
import androidx.lifecycle.viewModelScope
3840
import com.samples.storage.data.SampleFiles
@@ -41,20 +43,19 @@ import kotlinx.coroutines.launch
4143
import kotlinx.coroutines.withContext
4244
import okhttp3.OkHttpClient
4345
import okhttp3.Request
46+
import okhttp3.ResponseBody
4447
import java.io.File
45-
import java.io.OutputStream
4648
import java.net.URLConnection
4749
import java.nio.file.Files
4850
import java.nio.file.attribute.FileTime
4951

50-
// TODO(yrezgui): Use the viewModel in AddDocumentFragment (all the logic is already written)
52+
private const val TAG = "AddDocumentViewModel"
53+
5154
// TODO(yrezgui): Create a file details property and keep it in the savedStateHandle
5255
class AddDocumentViewModel(
5356
application: Application,
54-
private val savedStateHandle: SavedStateHandle
57+
savedStateHandle: SavedStateHandle
5558
) : AndroidViewModel(application) {
56-
private val TAG = "AddDocumentViewModel"
57-
5859
private val context: Context
5960
get() = getApplication()
6061

@@ -70,6 +71,37 @@ class AddDocumentViewModel(
7071
*/
7172
private val httpClient by lazy { OkHttpClient() }
7273

74+
/**
75+
* We keep the current [FileEntry] in the savedStateHandle to re-render it if there is a
76+
* configuration change and we expose it as a [LiveData] to the UI
77+
*/
78+
private var _isDownloading: MutableLiveData<Boolean> = MutableLiveData(false)
79+
val isDownloading: LiveData<Boolean> = _isDownloading
80+
81+
/**
82+
* We keep the current [FileEntry] in the savedStateHandle to re-render it if there is a
83+
* configuration change and we expose it as a [LiveData] to the UI
84+
*/
85+
private var _currentFileEntry: MutableLiveData<FileEntry> = MutableLiveData(null)
86+
val currentFileEntry: LiveData<FileEntry> = _currentFileEntry
87+
88+
init {
89+
val fileEntryBundle = savedStateHandle.get<Bundle>("current_file")
90+
if (fileEntryBundle != null) {
91+
_currentFileEntry.value = FileEntry.fromBundle(fileEntryBundle)
92+
}
93+
savedStateHandle.setSavedStateProvider("current_file") { // saveState()
94+
if (_currentFileEntry.value != null) {
95+
_currentFileEntry.value!!.toBundle()
96+
} else {
97+
Bundle()
98+
}
99+
}
100+
}
101+
102+
/**
103+
* Generate random filename when saving a new file
104+
*/
73105
private fun generateFilename(extension: String) = "${System.currentTimeMillis()}.$extension"
74106

75107
/**
@@ -91,77 +123,109 @@ class AddDocumentViewModel(
91123

92124
@Suppress("BlockingMethodInNonBlockingContext")
93125
suspend fun addRandomFile() {
126+
_isDownloading.postValue(true)
127+
94128
val randomRemoteUrl = SampleFiles.nonMedia.random()
95-
val extension = randomRemoteUrl.substring(randomRemoteUrl.lastIndexOf("."))
129+
val extension = randomRemoteUrl.substring(randomRemoteUrl.lastIndexOf(".") + 1)
96130
val filename = generateFilename(extension)
97-
lateinit var outputStream: OutputStream
98131

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")
132+
withContext(Dispatchers.IO) {
133+
try {
134+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
135+
val uri = addFileToDownloadsApi29(filename)
136+
val outputStream = context.contentResolver.openOutputStream(uri, "w")
137+
?: throw Exception("ContentResolver couldn't open $uri outputStream")
138+
139+
val responseBody = downloadFileFromInternet(randomRemoteUrl)
140+
141+
if (responseBody == null) {
142+
_isDownloading.postValue(false)
143+
return@withContext
144+
}
145+
146+
// .use is an extension function that closes the output stream where we're
147+
// saving the file content once its lambda is finished being executed
148+
responseBody.use {
149+
outputStream.use {
150+
responseBody.byteStream().copyTo(it)
151+
}
152+
}
153+
154+
Log.d(TAG, "File downloaded ($uri)")
104155

105-
downloadFileFromInternet(randomRemoteUrl, outputStream)
106-
Log.d(TAG, "File downloaded ($uri)")
156+
val path = getMediaStoreEntryPathApi29(uri)
157+
?: throw Exception("ContentResolver couldn't find $uri")
107158

108-
val path = getMediaStoreEntryPathApi29(uri)
109-
?: throw Exception("ContentResolver couldn't find $uri")
110-
scanFileApi(path) {
111-
Log.d(TAG, "MediaStore updated ($path)")
159+
// We scan the newly added file to make sure MediaStore.Downloads is always up
160+
// to date
161+
scanFilePath(path, responseBody.contentType().toString()) {
162+
Log.d(TAG, "MediaStore updated ($path)")
112163

113-
viewModelScope.launch {
114-
val fileDetails = getFileDetailsApi29(uri)
115-
Log.d(TAG, "New file: $fileDetails")
164+
viewModelScope.launch {
165+
val fileDetails = getFileDetailsApi29(uri)
166+
Log.d(TAG, "New file: $fileDetails")
167+
168+
_currentFileEntry.postValue(fileDetails)
169+
_isDownloading.postValue(false)
170+
}
116171
}
117-
}
118-
} else {
119-
val file = addFileToDownloadsApi21(filename)
120-
outputStream = file.outputStream()
172+
} else {
173+
val file = addFileToDownloadsApi21(filename)
174+
val outputStream = file.outputStream()
121175

122-
downloadFileFromInternet(randomRemoteUrl, outputStream)
123-
Log.d(TAG, "File downloaded (${file.absolutePath})")
176+
val responseBody = downloadFileFromInternet(randomRemoteUrl)
124177

125-
scanFileApi(file.absolutePath) {
126-
Log.d(TAG, "MediaStore updated (${file.absolutePath})")
178+
if (responseBody == null) {
179+
_isDownloading.postValue(false)
180+
return@withContext
181+
}
127182

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)
183+
// .use is an extension function that closes the output stream where we're
184+
// saving the file content once its lambda is finished being executed
185+
responseBody.use {
186+
outputStream.use {
187+
responseBody.byteStream().copyTo(it)
133188
}
189+
}
190+
191+
Log.d(TAG, "File downloaded (${file.absolutePath})")
192+
193+
// We scan the newly added file to make sure MediaStore.Files is always up to
194+
// date
195+
scanFilePath(file.path, responseBody.contentType().toString()) {
196+
Log.d(TAG, "MediaStore updated ($file.path)")
134197

135-
Log.d(TAG, "New file: $fileDetails")
198+
viewModelScope.launch {
199+
val fileDetails = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
200+
getFileDetailsApi26(file.absolutePath)
201+
} else {
202+
getFileDetailsApi21(file.absolutePath)
203+
}
204+
Log.d(TAG, "New file: $fileDetails")
205+
206+
_currentFileEntry.postValue(fileDetails)
207+
_isDownloading.postValue(false)
208+
}
136209
}
137210
}
211+
} catch (e: Exception) {
212+
Log.e(TAG, e.toString())
213+
_isDownloading.postValue(false)
138214
}
139-
} catch (e: Exception) {
140-
Log.e(TAG, e.toString())
141215
}
142216
}
143217

144218
/**
145219
* Downloads a random file from internet and saves its content to the specified outputStream
146220
*/
147221
@Suppress("BlockingMethodInNonBlockingContext")
148-
private suspend fun downloadFileFromInternet(
149-
url: String,
150-
outputStream: OutputStream
151-
) {
222+
private suspend fun downloadFileFromInternet(url: String): ResponseBody? {
152223
// We use OkHttp to create HTTP request
153224
val request = Request.Builder().url(url).build()
154225

155-
withContext(Dispatchers.IO) {
226+
return withContext(Dispatchers.IO) {
156227
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-
}
228+
return@withContext response.body
165229
}
166230
}
167231

@@ -211,9 +275,9 @@ class AddDocumentViewModel(
211275
* size, even though the file is definitely not empty. MediaStore will eventually scan the file
212276
* but it's better to do it ourselves to have a fresher state whenever we can
213277
*/
214-
private suspend fun scanFileApi(path: String, callback: () -> Unit) {
278+
private suspend fun scanFilePath(path: String, mimeType: String, callback: () -> Unit) {
215279
withContext(Dispatchers.IO) {
216-
MediaScannerConnection.scanFile(context, arrayOf(path), emptyArray()) { path, uri ->
280+
MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, _ ->
217281
callback()
218282
}
219283
}

0 commit comments

Comments
 (0)