diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2e98e30f..dd28ab7e 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -28,10 +28,10 @@ jobs: steps: - uses: actions/checkout@v1 - - name: set up JDK 1.8 + - name: set up JDK 11 uses: actions/setup-java@v1 with: - java-version: 1.8 + java-version: 11 - name: Build project run: .github/scripts/gradlew_recursive.sh assembleDebug - name: Zip artifacts diff --git a/FileProvider/app/build.gradle b/FileProvider/app/build.gradle index 69b1d05d..43f14750 100644 --- a/FileProvider/app/build.gradle +++ b/FileProvider/app/build.gradle @@ -16,16 +16,14 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' android { - compileSdkVersion 29 - buildToolsVersion "29.0.3" + compileSdkVersion 31 defaultConfig { applicationId "com.example.graygallery" minSdkVersion 21 - targetSdkVersion 29 + targetSdkVersion 31 versionCode 1 versionName "1.0" @@ -63,7 +61,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'com.google.android.material:material:1.4.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.0' - implementation 'androidx.fragment:fragment-ktx:1.4.0-alpha07' + implementation 'androidx.fragment:fragment-ktx:1.4.0-alpha09' implementation 'androidx.navigation:navigation-fragment:2.3.5' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui:2.3.5' @@ -73,7 +71,7 @@ dependencies { implementation 'com.squareup.okhttp3:okhttp:4.7.2' implementation 'io.coil-kt:coil:0.11.0' - testImplementation 'junit:junit:4.13' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/FileProvider/app/src/main/AndroidManifest.xml b/FileProvider/app/src/main/AndroidManifest.xml index 712da8c2..7ad15058 100644 --- a/FileProvider/app/src/main/AndroidManifest.xml +++ b/FileProvider/app/src/main/AndroidManifest.xml @@ -40,7 +40,8 @@ + android:label="@string/app_name" + android:exported="true"> diff --git a/FileProvider/build.gradle b/FileProvider/build.gradle index 3fd3e9e3..29612fbe 100644 --- a/FileProvider/build.gradle +++ b/FileProvider/build.gradle @@ -16,13 +16,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.3.72" + ext.kotlin_version = "1.5.30" repositories { google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:7.0.1' + classpath 'com.android.tools.build:gradle:7.0.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/ScopedStorage/.gitignore b/ScopedStorage/.gitignore index 878c2cd5..aa724b77 100644 --- a/ScopedStorage/.gitignore +++ b/ScopedStorage/.gitignore @@ -1,7 +1,12 @@ *.iml .gradle /local.properties -.idea +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml .DS_Store /build /captures diff --git a/ScopedStorage/README.md b/ScopedStorage/README.md deleted file mode 100644 index 4679cb10..00000000 --- a/ScopedStorage/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Storage APIs Demo Repository - -This repository contains a single Android Studio project, composed of modules illustrating how to use the Storage APIs on Android from API 19 (KitKat) until API 30 (Android 11) diff --git a/ScopedStorage/app/build.gradle b/ScopedStorage/app/build.gradle index 43ae8cb7..73db3481 100644 --- a/ScopedStorage/app/build.gradle +++ b/ScopedStorage/app/build.gradle @@ -1,41 +1,23 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - plugins { id 'com.android.application' id 'kotlin-android' - id 'kotlin-parcelize' + id "org.jetbrains.kotlin.plugin.parcelize" } android { - compileSdkVersion 30 - buildToolsVersion "30.0.3" + compileSdk 31 defaultConfig { - applicationId "com.samples.storage" - minSdkVersion 21 - targetSdkVersion 30 + applicationId "com.samples.storage.scopedstorage" + minSdk 21 + targetSdk 31 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - } - - buildFeatures { - viewBinding true + vectorDrawables { + useSupportLibrary true + } } buildTypes { @@ -50,28 +32,40 @@ android { } kotlinOptions { jvmTarget = '1.8' + useIR = true + } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion compose_version + } + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } } } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'com.google.android.material:material:1.4.0' - implementation 'androidx.activity:activity-ktx:1.3.1' - implementation 'androidx.constraintlayout:constraintlayout:2.1.0' - implementation 'androidx.fragment:fragment-ktx:1.3.6' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' - implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' - implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' - implementation 'androidx.recyclerview:recyclerview:1.2.1' - implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'com.squareup.okhttp3:okhttp:4.9.1' - implementation 'com.github.bumptech.glide:glide:4.12.0' - annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' + implementation "androidx.compose.ui:ui:$compose_version" + implementation "androidx.compose.material:material:$compose_version" + implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" + implementation "androidx.compose.material:material-icons-extended:$compose_version" + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.activity:activity-compose:1.3.1' + + implementation "androidx.navigation:navigation-compose:2.4.0-alpha08" + implementation "androidx.compose.runtime:runtime-livedata:$compose_version" + implementation "com.github.skydoves:landscapist-glide:1.3.6" testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" + debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" } \ No newline at end of file diff --git a/ScopedStorage/app/src/main/AndroidManifest.xml b/ScopedStorage/app/src/main/AndroidManifest.xml index 7e6e501b..857ee37f 100644 --- a/ScopedStorage/app/src/main/AndroidManifest.xml +++ b/ScopedStorage/app/src/main/AndroidManifest.xml @@ -1,28 +1,16 @@ - - + package="com.samples.storage.scopedstorage"> - - + + - + android:theme="@style/Theme.ScopedStorage"> + diff --git a/ScopedStorage/app/src/main/assets/sample.jpg b/ScopedStorage/app/src/main/assets/sample.jpg new file mode 100644 index 00000000..331bfd0d Binary files /dev/null and b/ScopedStorage/app/src/main/assets/sample.jpg differ diff --git a/ScopedStorage/app/src/main/assets/sample.mp4 b/ScopedStorage/app/src/main/assets/sample.mp4 new file mode 100644 index 00000000..8b8f35b2 Binary files /dev/null and b/ScopedStorage/app/src/main/assets/sample.mp4 differ diff --git a/ScopedStorage/app/src/main/assets/sample.pdf b/ScopedStorage/app/src/main/assets/sample.pdf new file mode 100644 index 00000000..049db637 Binary files /dev/null and b/ScopedStorage/app/src/main/assets/sample.pdf differ diff --git a/ScopedStorage/app/src/main/assets/sample.zip b/ScopedStorage/app/src/main/assets/sample.zip new file mode 100644 index 00000000..e60dcaaa Binary files /dev/null and b/ScopedStorage/app/src/main/assets/sample.zip differ diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/ActionListAdapter.kt b/ScopedStorage/app/src/main/java/com/samples/storage/ActionListAdapter.kt deleted file mode 100644 index 8b7e65ac..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/ActionListAdapter.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.annotation.IdRes -import androidx.annotation.StringRes -import androidx.navigation.findNavController -import androidx.recyclerview.widget.RecyclerView - -data class Action(@StringRes val nameRes: Int, @IdRes val actionRes: Int) - -class ActionListAdapter(private val dataSet: Array) : - RecyclerView.Adapter() { - - /** - * Provide a reference to the type of views that you are using - * (custom ViewHolder). - */ - class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { - val textView: TextView = view.findViewById(R.id.textView) - - init { - // Define click listener for the ViewHolder's View. - } - } - - // Create new views (invoked by the layout manager) - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder { - // Create a new view, which defines the UI of the list item - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.list_row_item, viewGroup, false) - - return ViewHolder(view) - } - - // Replace the contents of a view (invoked by the layout manager) - override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) { - val context = viewHolder.textView.context - - // Get element from your dataset at this position and replace the - // contents of the view with that element - viewHolder.textView.text = context.getString(dataSet[position].nameRes) - viewHolder.textView.setOnClickListener { - it.findNavController().navigate(dataSet[position].actionRes) - } - } - - // Return the size of your dataset (invoked by the layout manager) - override fun getItemCount() = dataSet.size -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/MainActivity.kt b/ScopedStorage/app/src/main/java/com/samples/storage/MainActivity.kt deleted file mode 100644 index 29a8e030..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/MainActivity.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage - -import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import androidx.navigation.findNavController -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.setupActionBarWithNavController - -class MainActivity : AppCompatActivity() { - private lateinit var appBarConfiguration: AppBarConfiguration - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(R.layout.main_activity) - - val navController = findNavController(R.id.nav_host_fragment) - - appBarConfiguration = AppBarConfiguration(navController.graph) - setupActionBarWithNavController(navController, appBarConfiguration) - } - - override fun onSupportNavigateUp(): Boolean { - val navController = findNavController(R.id.nav_host_fragment) - return navController.navigateUp(appBarConfiguration) || - super.onSupportNavigateUp() - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/MainFragment.kt b/ScopedStorage/app/src/main/java/com/samples/storage/MainFragment.kt deleted file mode 100644 index 1b2269d0..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/MainFragment.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.LinearLayoutManager -import com.samples.storage.databinding.FragmentListBinding - -private val apiList = arrayOf( - Action(R.string.demo_mediastore, R.id.action_mainFragment_to_mediaStoreFragment), - Action(R.string.demo_saf, R.id.action_mainFragment_to_safFragment) -) - -class MainFragment : Fragment() { - private var _binding: FragmentListBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentListBinding.inflate(inflater, container, false) - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.adapter = ActionListAdapter(apiList) - - binding.recyclerView - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/data/SampleFiles.kt b/ScopedStorage/app/src/main/java/com/samples/storage/data/SampleFiles.kt deleted file mode 100644 index 82fc3e68..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/data/SampleFiles.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.data - -/** - * List of remote sample files to be used in the different samples - */ -object SampleFiles { - val images = listOf( - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Balcony%20Toss/card.jpg", - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Dance%20Search/card.jpg", - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Extra%20Spicy/card.jpg", - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Get%20Your%20Money's%20Worth/card.jpg" - ) - - val video = listOf( - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Balcony%20Toss.mp4", - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Dance%20Search.mp4", - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Extra%20Spicy.mp4", - "https://storage.googleapis.com/android-tv/Sample%20videos/Demo%20Slam/Google%20Demo%20Slam_%20Get%20Your%20Money's%20Worth.mp4" - ) - - val media = images + video - - val texts = listOf( - "https://raw.githubusercontent.com/android/storage-samples/main/README.md", - "https://raw.githubusercontent.com/android/security-samples/main/README.md" - ) - - val documents = listOf( - "https://developer.android.com/images/jetpack/compose/compose-testing-cheatsheet.pdf", - "https://developer.android.com/images/training/dependency-injection/hilt-annotations.pdf", - "https://android.github.io/android-test/downloads/espresso-cheat-sheet-2.1.0.pdf" - ) - - val archives = listOf( - "https://github.com/android/storage-samples/archive/refs/heads/main.zip", - "https://github.com/android/security-samples/archive/refs/heads/main.zip" - ) - - val nonMedia = texts + documents + archives -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentFragment.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentFragment.kt deleted file mode 100644 index 673420eb..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentFragment.kt +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.Manifest -import android.os.Bundle -import android.text.format.DateUtils -import android.text.format.Formatter -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.samples.storage.R -import com.samples.storage.databinding.FragmentAddDocumentBinding -import kotlinx.coroutines.launch - -class AddDocumentFragment : Fragment() { - private var _binding: FragmentAddDocumentBinding? = null - private val binding get() = _binding!! - private val viewModel: AddDocumentViewModel by viewModels() - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentAddDocumentBinding.inflate(inflater, container, false) - - // Every time currentFileEntry is changed, we update the file details - viewModel.currentFileEntry.observe(viewLifecycleOwner) { fileDetails -> - if (fileDetails == null) { - binding.fileDetails.visibility = View.GONE - return@observe - } - - binding.filename.text = fileDetails.filename - binding.filePath.text = fileDetails.path - binding.fileSizeAndMimeType.text = getString( - R.string.mediastore_file_size_and_mimetype, - Formatter.formatShortFileSize(context, fileDetails.size), - fileDetails.mimeType - ) - binding.fileAddedAt.text = getString( - R.string.mediastore_file_added_at, - DateUtils.formatDateTime( - context, - fileDetails.addedAt, - DateUtils.FORMAT_SHOW_TIME or - DateUtils.FORMAT_SHOW_DATE or - DateUtils.FORMAT_SHOW_YEAR or - DateUtils.FORMAT_SHOW_WEEKDAY or - DateUtils.FORMAT_ABBREV_ALL - ) - ) - binding.fileDetails.visibility = View.VISIBLE - } - - // Every time isDownloading is changed, we toggle the download button - viewModel.isDownloading.observe(viewLifecycleOwner) { isDownloading -> - binding.downloadRandomFileFromInternet.isEnabled = !isDownloading - } - - binding.requestPermissionButton.setOnClickListener { - actionRequestPermission.launch( - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) - ) - } - - binding.downloadRandomFileFromInternet.setOnClickListener { - viewLifecycleOwner.lifecycleScope.launch { - - if (viewModel.canAddDocument) { - viewModel.addRandomFile() - } else { - showPermissionSection() - } - } - } - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onResume() { - super.onResume() - handlePermissionSectionVisibility() - } - - private val actionRequestPermission = - registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { - handlePermissionSectionVisibility() - } - - private fun handlePermissionSectionVisibility() { - if (viewModel.canAddDocument) { - hidePermissionSection() - } else { - showPermissionSection() - } - } - - private fun hidePermissionSection() { - binding.permissionSection.visibility = View.GONE - binding.actions.visibility = View.VISIBLE - } - - private fun showPermissionSection() { - binding.permissionSection.visibility = View.VISIBLE - binding.actions.visibility = View.GONE - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentViewModel.kt deleted file mode 100644 index be677871..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddDocumentViewModel.kt +++ /dev/null @@ -1,336 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.app.Application -import android.content.ContentValues -import android.content.Context -import android.content.pm.PackageManager -import android.media.MediaScannerConnection -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.os.Environment.DIRECTORY_DOWNLOADS -import android.os.Parcelable -import android.provider.MediaStore -import android.provider.MediaStore.Files.FileColumns -import android.util.Log -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import com.samples.storage.data.SampleFiles -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.parcelize.Parcelize -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.ResponseBody -import java.io.File - -private const val TAG = "AddDocumentViewModel" - -class AddDocumentViewModel( - application: Application, - private val savedStateHandle: SavedStateHandle -) : AndroidViewModel(application) { - private val context: Context - get() = getApplication() - - /** - * Check ability to add document in the Download folder or not - */ - val canAddDocument: Boolean - get() = canAddDocumentPermission(context) - - /** - * Using lazy to instantiate the [OkHttpClient] only when accessing it, not when the viewmodel - * is created - */ - private val httpClient by lazy { OkHttpClient() } - - /** - * We keep the current [FileEntry] in the savedStateHandle to re-render it if there is a - * configuration change and we expose it as a [LiveData] to the UI - */ - private var _isDownloading: MutableLiveData = MutableLiveData(false) - val isDownloading: LiveData = _isDownloading - - /** - * We keep the current [FileEntry] in the savedStateHandle to re-render it if there is a - * configuration change and we expose it as a [LiveData] to the UI - */ - val currentFileEntry = savedStateHandle.getLiveData("current_file") - - /** - * Generate random filename when saving a new file - */ - private fun generateFilename(extension: String) = "${System.currentTimeMillis()}.$extension" - - /** - * Check if the app can writes on the shared storage - * - * On Android 10 (API 29), we can add files to the Downloads folder without having to request the - * [WRITE_EXTERNAL_STORAGE] permission, so we only check on pre-API 29 devices - */ - private fun canAddDocumentPermission(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - true - } else { - ContextCompat.checkSelfPermission( - context, - WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - } - } - - @Suppress("BlockingMethodInNonBlockingContext") - suspend fun addRandomFile() { - _isDownloading.postValue(true) - - val randomRemoteUrl = SampleFiles.nonMedia.random() - val extension = randomRemoteUrl.substring(randomRemoteUrl.lastIndexOf(".") + 1) - val filename = generateFilename(extension) - - withContext(Dispatchers.IO) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val newFileUri = addFileToDownloadsApi29(filename) - val outputStream = context.contentResolver.openOutputStream(newFileUri, "w") - ?: throw Exception("ContentResolver couldn't open $newFileUri outputStream") - - val responseBody = downloadFileFromInternet(randomRemoteUrl) - - if (responseBody == null) { - _isDownloading.postValue(false) - return@withContext - } - - // .use is an extension function that closes the output stream where we're - // saving the file content once its lambda is finished being executed - responseBody.use { - outputStream.use { - responseBody.byteStream().copyTo(it) - } - } - - Log.d(TAG, "File downloaded ($newFileUri)") - - val path = getMediaStoreEntryPathApi29(newFileUri) - ?: throw Exception("ContentResolver couldn't find $newFileUri") - - // We scan the newly added file to make sure MediaStore.Downloads is always up - // to date - scanFilePath(path, responseBody.contentType().toString()) { uri -> - Log.d(TAG, "MediaStore updated ($path, $uri)") - - viewModelScope.launch { - val fileDetails = getFileDetails(uri) - Log.d(TAG, "New file: $fileDetails") - - savedStateHandle["current_file"] = fileDetails - _isDownloading.postValue(false) - } - } - } else { - val file = addFileToDownloadsApi21(filename) - val outputStream = file.outputStream() - - val responseBody = downloadFileFromInternet(randomRemoteUrl) - - if (responseBody == null) { - _isDownloading.postValue(false) - return@withContext - } - - // .use is an extension function that closes the output stream where we're - // saving the file content once its lambda is finished being executed - responseBody.use { - outputStream.use { - responseBody.byteStream().copyTo(it) - } - } - - Log.d(TAG, "File downloaded (${file.absolutePath})") - - // We scan the newly added file to make sure MediaStore.Files is always up to - // date - scanFilePath(file.path, responseBody.contentType().toString()) { uri -> - Log.d(TAG, "MediaStore updated ($file.path, $uri)") - - viewModelScope.launch { - val fileDetails = getFileDetails(uri) - Log.d(TAG, "New file: $fileDetails") - - savedStateHandle["current_file"] = fileDetails - _isDownloading.postValue(false) - } - } - } - } catch (e: Exception) { - Log.e(TAG, e.toString()) - _isDownloading.postValue(false) - } - } - } - - /** - * Downloads a random file from internet and saves its content to the specified outputStream - */ - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun downloadFileFromInternet(url: String): ResponseBody? { - // We use OkHttp to create HTTP request - val request = Request.Builder().url(url).build() - - return withContext(Dispatchers.IO) { - val response = httpClient.newCall(request).execute() - return@withContext response.body - } - } - - /** - * Create a file inside the Download folder using java.io API - */ - @Suppress("BlockingMethodInNonBlockingContext") - private suspend fun addFileToDownloadsApi21(filename: String): File { - val downloadsFolder = Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS) - - // Get path of the destination where the file will be saved - val newNonMediaFile = File(downloadsFolder, filename) - - return withContext(Dispatchers.IO) { - // Create new file if it does not exist, throw exception otherwise - if (!newNonMediaFile.createNewFile()) { - throw Exception("File ${newNonMediaFile.name} already exists") - } - - return@withContext newNonMediaFile - } - } - - /** - * Create a file inside the Download folder using MediaStore API - */ - @Suppress("BlockingMethodInNonBlockingContext") - @RequiresApi(Build.VERSION_CODES.Q) - private suspend fun addFileToDownloadsApi29(filename: String): Uri { - val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - - return withContext(Dispatchers.IO) { - val newFile = ContentValues().apply { - put(MediaStore.Downloads.DISPLAY_NAME, filename) - } - - // This method will perform a binder transaction which is better to execute off the main - // thread - return@withContext context.contentResolver.insert(collection, newFile) - ?: throw Exception("MediaStore Uri couldn't be created") - } - } - - /** - * When adding a file (using java.io or ContentResolver APIs), MediaStore might not be aware of - * the new entry or doesn't have an updated version of it. That's why some entries have 0 bytes - * size, even though the file is definitely not empty. MediaStore will eventually scan the file - * but it's better to do it ourselves to have a fresher state whenever we can - */ - private suspend fun scanFilePath(path: String, mimeType: String, callback: (uri: Uri) -> Unit) { - withContext(Dispatchers.IO) { - MediaScannerConnection.scanFile(context, arrayOf(path), arrayOf(mimeType)) { _, uri -> - callback(uri) - } - } - } - - /** - * Get a path for a MediaStore entry as it's needed when calling MediaScanner - */ - private suspend fun getMediaStoreEntryPathApi29(uri: Uri): String? { - return withContext(Dispatchers.IO) { - val cursor = context.contentResolver.query( - uri, - arrayOf(FileColumns.DATA), - null, - null, - null - ) ?: return@withContext null - - cursor.use { - if (!cursor.moveToFirst()) { - return@withContext null - } - - return@withContext cursor.getString(cursor.getColumnIndexOrThrow(FileColumns.DATA)) - } - } - } - - /** - * Get file details using the MediaStore API - */ - private suspend fun getFileDetails(uri: Uri): FileEntry? { - return withContext(Dispatchers.IO) { - val cursor = context.contentResolver.query( - uri, - arrayOf( - FileColumns.DISPLAY_NAME, - FileColumns.SIZE, - FileColumns.MIME_TYPE, - FileColumns.DATE_ADDED, - FileColumns.DATA - ), - null, - null, - null - ) ?: return@withContext null - - cursor.use { - if (!cursor.moveToFirst()) { - return@withContext null - } - - val displayNameColumn = cursor.getColumnIndexOrThrow(FileColumns.DISPLAY_NAME) - val sizeColumn = cursor.getColumnIndexOrThrow(FileColumns.SIZE) - val mimeTypeColumn = cursor.getColumnIndexOrThrow(FileColumns.MIME_TYPE) - val dateAddedColumn = cursor.getColumnIndexOrThrow(FileColumns.DATE_ADDED) - val dataColumn = cursor.getColumnIndexOrThrow(FileColumns.DATA) - - return@withContext FileEntry( - filename = cursor.getString(displayNameColumn), - size = cursor.getLong(sizeColumn), - mimeType = cursor.getString(mimeTypeColumn), - // FileColumns.DATE_ADDED is in seconds, not milliseconds - addedAt = cursor.getLong(dateAddedColumn) * 1000, - path = cursor.getString(dataColumn), - ) - } - } - } -} - -@Parcelize -data class FileEntry( - val filename: String, - val size: Long, - val mimeType: String, - val addedAt: Long, - val path: String -) : Parcelable diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddMediaFragment.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddMediaFragment.kt deleted file mode 100644 index 2c761e1b..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddMediaFragment.kt +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.Manifest.permission.READ_EXTERNAL_STORAGE -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions -import androidx.activity.result.contract.ActivityResultContracts.TakePicture -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.bumptech.glide.Glide -import com.samples.storage.databinding.FragmentAddMediaBinding -import kotlinx.coroutines.launch - -class AddMediaFragment : Fragment() { - private var _binding: FragmentAddMediaBinding? = null - private val binding get() = _binding!! - private val viewModel: AddMediaViewModel by viewModels() - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentAddMediaBinding.inflate(inflater, container, false) - - // Every time currentMediaUri is changed, we update the ImageView - viewModel.currentMediaUri.observe(viewLifecycleOwner) { uri -> - Glide.with(this).load(uri).into(binding.mediaThumbnail) - } - - binding.requestPermissionButton.setOnClickListener { - actionRequestPermission.launch(arrayOf(READ_EXTERNAL_STORAGE, WRITE_EXTERNAL_STORAGE)) - } - - binding.takePictureButton.setOnClickListener { - viewLifecycleOwner.lifecycleScope.launch { - - if (viewModel.canWriteInMediaStore) { - viewModel.createPhotoUri(Source.CAMERA)?.let { uri -> - viewModel.saveTemporarilyPhotoUri(uri) - actionTakePicture.launch(uri) - } - } else { - showPermissionSection() - } - } - } - - binding.takeVideoButton.setOnClickListener { - viewLifecycleOwner.lifecycleScope.launch { - - if (viewModel.canWriteInMediaStore) { - viewModel.createVideoUri(Source.CAMERA)?.let { uri -> - actionTakeVideo.launch(uri) - } - } else { - showPermissionSection() - } - } - } - - binding.downloadImageFromInternetButton.setOnClickListener { - - if (viewModel.canWriteInMediaStore) { - binding.downloadImageFromInternetButton.isEnabled = false - viewModel.saveRandomImageFromInternet { - // We re-enable the button once the download is done - // Keep in mind the logic is basic as it doesn't handle errors - binding.downloadImageFromInternetButton.isEnabled = true - } - } else { - showPermissionSection() - } - } - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - override fun onResume() { - super.onResume() - - handlePermissionSectionVisibility() - } - - private fun handlePermissionSectionVisibility() { - if (viewModel.canWriteInMediaStore) { - hidePermissionSection() - } else { - showPermissionSection() - } - } - - private fun hidePermissionSection() { - binding.permissionSection.visibility = View.GONE - binding.actions.visibility = View.VISIBLE - } - - private fun showPermissionSection() { - binding.permissionSection.visibility = View.VISIBLE - binding.actions.visibility = View.GONE - } - - private val actionRequestPermission = registerForActivityResult(RequestMultiplePermissions()) { - handlePermissionSectionVisibility() - } - - private val actionTakePicture = registerForActivityResult(TakePicture()) { success -> - if (!success) { - Log.d(tag, "Image taken FAIL") - return@registerForActivityResult - } - - Log.d(tag, "Image taken SUCCESS") - - viewModel.temporaryPhotoUri?.let { - viewModel.loadCameraMedia(it) - viewModel.saveTemporarilyPhotoUri(null) - } - } - - private val actionTakeVideo = registerForActivityResult(CustomTakeVideo()) { uri -> - if (uri == null) { - Log.d(tag, "Video taken FAIL") - return@registerForActivityResult - } - - Log.d(tag, "Video taken SUCCESS") - viewModel.loadCameraMedia(uri) - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddMediaViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddMediaViewModel.kt deleted file mode 100644 index 4e76fa3e..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/AddMediaViewModel.kt +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.app.Application -import android.content.ContentValues -import android.content.Context -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import androidx.core.content.ContextCompat.checkSelfPermission -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.LiveData -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request - -/** - * URL returning random picture provided by Unsplash. Read more here: https://source.unsplash.com - */ -private const val RANDOM_IMAGE_URL = "https://source.unsplash.com/random/500x500" - -class AddMediaViewModel(application: Application, private val savedStateHandle: SavedStateHandle) : AndroidViewModel(application) { - - private val context: Context - get() = getApplication() - - val canWriteInMediaStore: Boolean - get() = checkMediaStorePermission(context) - - /** - * Using lazy to instantiate the [OkHttpClient] only when accessing it, not when the viewmodel - * is created - */ - private val httpClient by lazy { OkHttpClient() } - - /** - * We keep the current media [Uri] in the savedStateHandle to re-render it if there is a - * configuration change and we expose it as a [LiveData] to the UI - */ - val currentMediaUri: LiveData = savedStateHandle.getLiveData("currentMediaUri") - - /** - * TakePicture activityResult action isn't returning the [Uri] once the image has been taken, so - * we need to save the temporarily created URI in [savedStateHandle] until we handle its result - */ - fun saveTemporarilyPhotoUri(uri: Uri?) { - savedStateHandle["temporaryPhotoUri"] = uri - } - - val temporaryPhotoUri: Uri? - get() = savedStateHandle.get("temporaryPhotoUri") - - /** - * [loadCameraMedia] is called when TakePicture or TakeVideo intent is returning a - * successful result, that we set to the currentMediaUri property, which will - * trigger to load the thumbnail in the UI - */ - fun loadCameraMedia(uri: Uri) { - savedStateHandle["currentMediaUri"] = uri - } - - /** - * We create a [Uri] where the image will be stored - */ - suspend fun createPhotoUri(source: Source): Uri? { - val imageCollection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - } else { - MediaStore.Images.Media.EXTERNAL_CONTENT_URI - } - - return withContext(Dispatchers.IO) { - val newImage = ContentValues().apply { - put(MediaStore.Images.Media.DISPLAY_NAME, generateFilename(source, "jpg")) - } - - // This method will perform a binder transaction which is better to execute off the main - // thread - return@withContext context.contentResolver.insert(imageCollection, newImage) - } - } - - /** - * We create a [Uri] where the camera will store the video - */ - suspend fun createVideoUri(source: Source): Uri? { - val videoCollection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) - } else { - MediaStore.Video.Media.EXTERNAL_CONTENT_URI - } - - return withContext(Dispatchers.IO) { - val newVideo = ContentValues().apply { - put(MediaStore.Video.Media.DISPLAY_NAME, generateFilename(source, "mp4")) - } - - // This method will perform a binder transaction which is better to execute off the main - // thread - return@withContext context.contentResolver.insert(videoCollection, newVideo) - } - } - - /** - * [saveRandomImageFromInternet] downloads a random image from unsplash.com and saves its - * content - */ - fun saveRandomImageFromInternet(callback: () -> Unit) { - viewModelScope.launch { - val imageUri = createPhotoUri(Source.INTERNET) - // We use OkHttp to create HTTP request - val request = Request.Builder().url(RANDOM_IMAGE_URL).build() - - withContext(Dispatchers.IO) { - - imageUri?.let { destinationUri -> - val response = httpClient.newCall(request).execute() - - // .use is an extension function that closes the output stream where we're - // saving the image content once its lambda is finished being executed - response.body?.use { responseBody -> - context.contentResolver.openOutputStream(destinationUri, "w")?.use { - responseBody.byteStream().copyTo(it) - - /** - * We can't set savedStateHandle within a background thread, so we do it - * within the [Dispatchers.Main], which execute its coroutines on the - * main thread - */ - withContext(Dispatchers.Main) { - savedStateHandle["currentMediaUri"] = destinationUri - callback() - } - } - } - } - } - } - } -} - -/** - * Check if the app can writes on the shared storage - * - * On Android 10 (API 29), we can add media to MediaStore without having to request the - * [WRITE_EXTERNAL_STORAGE] permission, so we only check on pre-API 29 devices - */ -private fun checkMediaStorePermission(context: Context): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - true - } else { - checkSelfPermission(context, WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED - } -} - -enum class Source { - CAMERA, INTERNET -} - -private fun generateFilename(source: Source, extension: String): String { - return when (source) { - Source.CAMERA -> { - "camera-${System.currentTimeMillis()}.$extension" - } - Source.INTERNET -> { - "internet-${System.currentTimeMillis()}.$extension" - } - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/CustomTakeVideo.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/CustomTakeVideo.kt deleted file mode 100644 index 51139947..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/CustomTakeVideo.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.app.Activity -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.provider.MediaStore -import androidx.activity.result.contract.ActivityResultContract - -class CustomTakeVideo : ActivityResultContract() { - override fun createIntent(context: Context, input: Uri): Intent { - return Intent(MediaStore.ACTION_VIDEO_CAPTURE) - .putExtra(MediaStore.EXTRA_OUTPUT, input) - } - - override fun getSynchronousResult(context: Context, input: Uri): SynchronousResult? { - return null - } - - override fun parseResult(resultCode: Int, intent: Intent?): Uri? { - return if (intent == null || resultCode != Activity.RESULT_OK) { - null - } else { - intent.data - } - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/DeleteMediaFragment.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/DeleteMediaFragment.kt deleted file mode 100644 index c9dc691b..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/DeleteMediaFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.samples.storage.databinding.FragmentDemoBinding - -// TODO(yrezgui): Finish this demo -class DeleteMediaFragment : Fragment() { - private var _binding: FragmentDemoBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentDemoBinding.inflate(inflater, container, false) - val view = binding.root - return view - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/EditMediaFragment.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/EditMediaFragment.kt deleted file mode 100644 index 9a15af84..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/EditMediaFragment.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import com.samples.storage.databinding.FragmentDemoBinding - -// TODO(yrezgui): Finish this demo -class EditMediaFragment : Fragment() { - private var _binding: FragmentDemoBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentDemoBinding.inflate(inflater, container, false) - val view = binding.root - return view - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/MediaStoreFragment.kt b/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/MediaStoreFragment.kt deleted file mode 100644 index 4705653e..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/mediastore/MediaStoreFragment.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.mediastore - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.LinearLayoutManager -import com.samples.storage.Action -import com.samples.storage.ActionListAdapter -import com.samples.storage.R -import com.samples.storage.databinding.FragmentListBinding - -private val demoList = arrayOf( - Action(R.string.mediastore_add, R.id.action_mediaStoreFragment_to_addMediaFragment), - Action(R.string.mediastore_edit, R.id.action_mediaStoreFragment_to_editMediaFragment), - Action(R.string.mediastore_delete, R.id.action_mediaStoreFragment_to_deleteMediaFragment), - Action(R.string.mediastore_downloads, R.id.action_mediaStoreFragment_to_addDocumentFragment), -) - -class MediaStoreFragment : Fragment() { - private var _binding: FragmentListBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentListBinding.inflate(inflater, container, false) - - binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) - binding.recyclerView.adapter = ActionListAdapter(demoList) - - binding.recyclerView - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/saf/SafFragment.kt b/ScopedStorage/app/src/main/java/com/samples/storage/saf/SafFragment.kt deleted file mode 100644 index a5138524..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/saf/SafFragment.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.saf - -import android.os.Bundle -import android.util.Log -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts.CreateDocument -import androidx.activity.result.contract.ActivityResultContracts.OpenDocument -import androidx.activity.result.contract.ActivityResultContracts.OpenDocumentTree -import androidx.documentfile.provider.DocumentFile -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import com.samples.storage.R -import com.samples.storage.databinding.FragmentSafBinding -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -private const val DEFAULT_FILE_NAME = "SAF Demo File.txt" - -/** - * Fragment that demonstrates the most common ways to work with documents via the - * Storage Access Framework (SAF). - */ -class SafFragment : Fragment() { - private var _binding: FragmentSafBinding? = null - private val binding get() = _binding!! - - private val viewModel: SafFragmentViewModel by viewModels() - - private val actionCreateDocument = registerForActivityResult(CreateDocument()) { uri -> - // If the user returns to this fragment without creating a file, uri will be null - // In this case, we return void - val documentUri = uri ?: return@registerForActivityResult - - // If we can't instantiate a `DocumentFile`, it probably means the file has been removed - // or became unavailable (if the SD card has been removed). - // In this case, we return void - val documentFile = DocumentFile.fromSingleUri(requireContext(), documentUri) - ?: return@registerForActivityResult - - // We launch a coroutine within the lifecycle of the viewmodel. The coroutine will be - // automatically cancelled if the viewmodel is cleared - viewLifecycleOwner.lifecycleScope.launch { - @Suppress("BlockingMethodInNonBlockingContext") - val documentStream = withContext(Dispatchers.IO) { - requireContext().contentResolver.openOutputStream(documentUri) - } ?: return@launch - - val text = viewModel.createDocumentExample(documentStream) - binding.output.text = - getString(R.string.saf_create_file_output, documentFile.name, text) - } - - Log.d("SafFragment", "Created: ${documentFile.name}, type ${documentFile.type}") - } - - private val actionOpenDocument = registerForActivityResult(OpenDocument()) { uri -> - // If the user returns to this fragment without selecting a file, uri will be null - // In this case, we return void - val documentUri = uri ?: return@registerForActivityResult - - // If we can't instantiate a `DocumentFile`, it probably means the file has been removed - // or became unavailable (if the SD card has been removed). - // In this case, we return void - val documentFile = DocumentFile.fromSingleUri(requireContext(), documentUri) - ?: return@registerForActivityResult - - viewLifecycleOwner.lifecycleScope.launch { - @Suppress("BlockingMethodInNonBlockingContext") - val documentStream = withContext(Dispatchers.IO) { - requireContext().contentResolver.openInputStream(documentUri) - } ?: return@launch - - val text = viewModel.openDocumentExample(documentStream) - binding.output.text = getString(R.string.saf_open_file_output, documentFile.name, text) - } - } - - private val actionOpenDocumentTree = registerForActivityResult(OpenDocumentTree()) { uri -> - val documentUri = uri ?: return@registerForActivityResult - val context = requireContext().applicationContext - val parentFolder = DocumentFile.fromTreeUri(context, documentUri) - ?: return@registerForActivityResult - - viewLifecycleOwner.lifecycleScope.launch { - val text = viewModel.listFiles(parentFolder) - .sortedBy { it.first } - .joinToString { it.first } - binding.output.text = getString(R.string.saf_folder_output, text) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentSafBinding.inflate(inflater, container, false) - - binding.createFile.setOnClickListener { - // We ask the user to create a file with a preferred default filename, which can be - // overwritten by the user - actionCreateDocument.launch(DEFAULT_FILE_NAME) - } - - binding.openFile.setOnClickListener { - // We ask the user to select any file. If we want to select a specific one, we would do - // this: `actionOpenDocument.launch(arrayOf("image/png", "image/gif"))` - actionOpenDocument.launch(arrayOf("*/*")) - } - - binding.openFolder.setOnClickListener { - // We ask the user to select a folder. We can specify a preferred folder to be opened - // if we have its URI and the device is running on API 26+ - actionOpenDocumentTree.launch(null) - } - - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/saf/SafFragmentViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/saf/SafFragmentViewModel.kt deleted file mode 100644 index 7bde32bf..00000000 --- a/ScopedStorage/app/src/main/java/com/samples/storage/saf/SafFragmentViewModel.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright 2021 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.samples.storage.saf - -import android.Manifest.permission.MANAGE_EXTERNAL_STORAGE -import android.content.ContentResolver -import android.content.Intent -import android.net.Uri -import android.provider.DocumentsContract -import androidx.documentfile.provider.DocumentFile -import androidx.lifecycle.ViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.io.InputStream -import java.io.OutputStream -import java.nio.charset.StandardCharsets -import java.security.MessageDigest -import java.util.Locale -import kotlin.random.Random - -/** Number of bytes to read at a time from an open stream */ -private const val FILE_BUFFER_SIZE_BYTES = 1024 - -/** - * ViewModel contains various examples for how to work with the contents of documents - * opened with the Storage Access Framework. - */ -class SafFragmentViewModel : ViewModel() { - - /** - * It's easiest to work with documents selected with the [Intent.ACTION_CREATE_DOCUMENT] action - * by simply opening an [OutputStream]. In this example we're generating some random text - * based on the words found in "Lorem Ipsum". - */ - suspend fun createDocumentExample(outputStream: OutputStream): String { - - @Suppress("BlockingMethodInNonBlockingContext") - return withContext(Dispatchers.IO) { - val lines = mutableListOf() - - for (lineNumber in 1..Random.nextInt(1, 5)) { - val line = "hello world ".repeat(Random.nextInt(1, 5)) - lines += line.capitalize(Locale.US) - } - - val contents = lines.joinToString(separator = System.lineSeparator()) - - outputStream.bufferedWriter(StandardCharsets.UTF_8).use { writer -> - writer.write(contents) - } - contents - } - } - - /** - * Similar to [Intent.ACTION_CREATE_DOCUMENT], it's easiest to work with documents selected - * with the [Intent.ACTION_OPEN_DOCUMENT] action by simply opening an [InputStream] or - * [OutputStream], depending on the need. In this example, since we don't want to disturb the - * contents of the file, we're just going to use an [InputStream] to generate a hash of - * the file's contents. - * - * Since hashing the contents of a large file may take some time, this is done in a - * suspend function with the [Dispatchers.IO] coroutine context. - */ - suspend fun openDocumentExample(inputStream: InputStream): String { - @Suppress("BlockingMethodInNonBlockingContext") - return withContext(Dispatchers.IO) { - inputStream.use { stream -> - val messageDigest = MessageDigest.getInstance("SHA-256") - - val buffer = ByteArray(FILE_BUFFER_SIZE_BYTES) - var bytesRead = stream.read(buffer) - while (bytesRead > 0) { - messageDigest.update(buffer, 0, bytesRead) - bytesRead = stream.read(buffer) - } - val hashResult = messageDigest.digest() - hashResult.joinToString(separator = ":") { "%02x".format(it) } - } - } - } - - /** - * Simple example of using [DocumentFile] to get all the documents in a folder (by using - * [Intent.ACTION_OPEN_DOCUMENT_TREE]). - * It's possible to use [DocumentsContract] and [ContentResolver] directly, but using - * [DocumentFile] allows us to access an easier to use API. - * - * While it's _possible_ to search across multiple directories and recursively work with files - * via SAF, there can be significant performance penalties to this type of usage. If your - * use case requires this, consider looking into the permission [MANAGE_EXTERNAL_STORAGE]. - * - * Accessing any field in the [DocumentFile] object, aside from [DocumentFile.getUri], - * ultimately performs a lookup with the system's [ContentResolver], and should thus be - * performed off the main thread, which is why we're doing this transformation from - * [DocumentFile] to file name and [Uri] in a coroutine. - */ - suspend fun listFiles(folder: DocumentFile): List> { - return withContext(Dispatchers.IO) { - if (folder.isDirectory) { - folder.listFiles().mapNotNull { file -> - if (file.name != null) Pair(file.name!!, file.uri) else null - } - } else { - emptyList() - } - } - } -} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/Demos.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/Demos.kt new file mode 100644 index 00000000..5ffd17ca --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/Demos.kt @@ -0,0 +1,125 @@ +package com.samples.storage.scopedstorage + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddAPhoto +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ImageSearch +import androidx.compose.material.icons.filled.NoteAdd +import androidx.compose.ui.graphics.vector.ImageVector + +data class Link(val name: String, val uri: Uri) + +data class Demo( + val route: String, + @StringRes val name: Int, + @StringRes val description: Int, + val icon: ImageVector, + val links: List +) + +object Demos { + val AddMediaFile = Demo( + route = "demo_add_media_file", + name = R.string.demo_add_media_file_name, + description = R.string.demo_add_media_file_description, + icon = Icons.Filled.AddPhotoAlternate, + links = listOf( + Link( + "Add MediaStore item guide", + Uri.parse("https://developer.android.com/training/data-storage/shared/media#add-item") + ) + ) + ) + + val CaptureMediaFile = Demo( + route = "demo_capture_media_file", + name = R.string.demo_capture_media_file_name, + description = R.string.demo_capture_media_file_description, + icon = Icons.Filled.AddAPhoto, + links = listOf( + Link( + "Take picture intent", + Uri.parse("https://developer.android.com/training/camera/photobasics#TaskCaptureIntent") + ), + Link( + "Add MediaStore item guide", + Uri.parse("https://developer.android.com/training/data-storage/shared/media#add-item") + ) + ) + ) + + val AddFileToDownloads = Demo( + route = "demo_add_file_to_downloads", + name = R.string.demo_add_file_to_downloads_name, + description = R.string.demo_add_file_to_downloads_description, + icon = Icons.Filled.AddCircle, + links = emptyList() + ) + + val EditMediaFile = Demo( + route = "demo_edit_media_file", + name = R.string.demo_edit_media_file_name, + description = R.string.demo_edit_media_file_description, + icon = Icons.Filled.Edit, + links = emptyList() + ) + + val DeleteMediaFile = Demo( + route = "demo_download_media_file", + name = R.string.demo_delete_media_file_name, + description = R.string.demo_delete_media_file_description, + icon = Icons.Filled.Delete, + links = emptyList() + ) + + val ListMediaFiles = Demo( + route = "demo_list_media_files", + name = R.string.demo_list_media_files_name, + description = R.string.demo_list_media_files_description, + icon = Icons.Filled.ImageSearch, + links = emptyList() + ) + + val SelectDocumentFile = Demo( + route = "demo_select_document_file", + name = R.string.demo_select_document_file_name, + description = R.string.demo_select_document_file_description, + icon = Icons.Filled.AttachFile, + links = emptyList() + ) + + val CreateDocumentFile = Demo( + route = "demo_create_document_file", + name = R.string.demo_create_document_file_name, + description = R.string.demo_create_document_file_description, + icon = Icons.Filled.NoteAdd, + links = emptyList() + ) + + val EditDocumentFile = Demo( + route = "demo_edit_document_file", + name = R.string.demo_edit_document_file_name, + description = R.string.demo_edit_document_file_description, + icon = Icons.Filled.Edit, + links = emptyList() + ) + + + val list = listOf( + AddMediaFile, + CaptureMediaFile, + AddFileToDownloads, + EditMediaFile, + DeleteMediaFile, + ListMediaFiles, + SelectDocumentFile, + CreateDocumentFile, + EditDocumentFile, + ) +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/HomeScreen.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/HomeScreen.kt new file mode 100644 index 00000000..348e95d9 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/HomeScreen.kt @@ -0,0 +1,42 @@ +package com.samples.storage.scopedstorage + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ListItem +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController + +const val HomeRoute = "home" + +@ExperimentalMaterialApi +@Composable +fun HomeScreen(navController: NavController) { + Scaffold( + topBar = { + TopAppBar(title = { Text(stringResource(R.string.app_name)) }) + }, + content = { paddingValues -> + LazyColumn(Modifier.padding(paddingValues)) { + items(Demos.list) { demo -> + ListItem( + modifier = Modifier.clickable { navController.navigate(demo.route) }, + text = { Text(stringResource(demo.name)) }, + secondaryText = { Text(stringResource(demo.description)) }, + icon = { Icon(demo.icon, contentDescription = null) } + ) + Divider() + } + } + } + ) +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/MainActivity.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/MainActivity.kt new file mode 100644 index 00000000..94bb49e9 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/MainActivity.kt @@ -0,0 +1,77 @@ +package com.samples.storage.scopedstorage + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.samples.storage.scopedstorage.mediastore.AddFileToDownloadsScreen +import com.samples.storage.scopedstorage.mediastore.AddMediaFileScreen +import com.samples.storage.scopedstorage.mediastore.CaptureMediaFileScreen +import com.samples.storage.scopedstorage.mediastore.ListMediaFileScreen +import com.samples.storage.scopedstorage.saf.SelectDocumentFileScreen +import com.samples.storage.scopedstorage.ui.theme.ScopedStorageTheme + +class MainActivity : ComponentActivity() { + @ExperimentalFoundationApi + @ExperimentalMaterialApi + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + ScopedStorageTheme { + val navController = rememberNavController() + + NavHost( + navController = navController, + startDestination = HomeRoute + ) { + composable(HomeRoute) { + HomeScreen(navController) + } + + composable(Demos.AddMediaFile.route) { + AddMediaFileScreen(navController) + } + composable(Demos.CaptureMediaFile.route) { + CaptureMediaFileScreen(navController) + } + composable(Demos.AddFileToDownloads.route) { + AddFileToDownloadsScreen(navController) + } + composable(Demos.EditMediaFile.route) { NotAvailableYetScreen() } + composable(Demos.DeleteMediaFile.route) { NotAvailableYetScreen() } + composable(Demos.ListMediaFiles.route) { + ListMediaFileScreen(navController) + } + composable(Demos.SelectDocumentFile.route) { + SelectDocumentFileScreen(navController) + } + composable(Demos.CreateDocumentFile.route) { NotAvailableYetScreen() } + composable(Demos.EditDocumentFile.route) { NotAvailableYetScreen() } + } + } + } + } +} + +@Composable +fun NotAvailableYetScreen() { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.demo_not_available_yet_label)) + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FilePreviewCard.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FilePreviewCard.kt new file mode 100644 index 00000000..3e851277 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FilePreviewCard.kt @@ -0,0 +1,86 @@ +package com.samples.storage.scopedstorage.common + +import android.graphics.Bitmap +import android.text.format.Formatter +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.skydoves.landscapist.glide.GlideImage + + +@Composable +fun MediaFilePreviewCard(resource: FileResource) { + val context = LocalContext.current + val formattedFileSize = Formatter.formatShortFileSize(context, resource.size) + val fileMetadata = "${resource.mimeType} - $formattedFileSize" + + Card( + elevation = 0.dp, + border = BorderStroke(width = 1.dp, color = compositeBorderColor()), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Column { + GlideImage( + imageModel = resource.uri, + contentScale = ContentScale.FillWidth, + contentDescription = null + ) + Column(modifier = Modifier.padding(16.dp)) { + Text(text = resource.filename, style = MaterialTheme.typography.subtitle2) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = fileMetadata, style = MaterialTheme.typography.caption) + Spacer(modifier = Modifier.height(12.dp)) + resource.path?.let { Text(text = it, style = MaterialTheme.typography.caption) } + } + } + } +} + +@Composable +fun DocumentFilePreviewCard(resource: FileResource) { + val context = LocalContext.current + val formattedFileSize = Formatter.formatShortFileSize(context, resource.size) + val fileMetadata = "${resource.mimeType} - $formattedFileSize" + + val thumbnail by produceState(null, resource.uri) { + value = SafUtils.getThumbnail(context, resource.uri) + } + + + Card( + elevation = 0.dp, + border = BorderStroke(width = 1.dp, color = compositeBorderColor()), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Column { + thumbnail?.let { Image(bitmap = it.asImageBitmap(), contentDescription = null) } + + Column(modifier = Modifier.padding(16.dp)) { + Text(text = resource.filename, style = MaterialTheme.typography.subtitle2) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = fileMetadata, style = MaterialTheme.typography.caption) + Spacer(modifier = Modifier.height(12.dp)) + resource.path?.let { Text(text = it, style = MaterialTheme.typography.caption) } + } + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FileResource.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FileResource.kt new file mode 100644 index 00000000..f8742761 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FileResource.kt @@ -0,0 +1,76 @@ +package com.samples.storage.scopedstorage.common + +import android.net.Uri +import android.os.Parcelable +import android.provider.MediaStore.Files.FileColumns +import kotlinx.parcelize.Parcelize + +/** + * Represents a File entry. + * + * @property uri Entry uri. + * @property filename File name with extension. + * @property size Size of the file in bytes. + * @property type Entry file type. + * @property mimeType Mime type of the file. + */ +@Parcelize +data class FileResource( + val uri: Uri, + val filename: String, + val size: Long, + val type: FileType, + val mimeType: String, + val path: String?, +) : Parcelable + +/** + * Media type enum class representing the [FileColumns.MEDIA_TYPE] column + */ +enum class FileType(val value: Int) { + /** + * Representing [FileColumns.MEDIA_TYPE_NONE] + */ + NONE(0), + + /** + * Representing [FileColumns.MEDIA_TYPE_IMAGE] + */ + IMAGE(1), + + /** + * Representing [FileColumns.MEDIA_TYPE_AUDIO] + */ + AUDIO(2), + + /** + * Representing [FileColumns.MEDIA_TYPE_VIDEO] + */ + VIDEO(3), + + /** + * Representing [FileColumns.MEDIA_TYPE_PLAYLIST] + */ + PLAYLIST(4), + + /** + * Representing [FileColumns.MEDIA_TYPE_SUBTITLE] + */ + SUBTITLE(5), + + /** + * Representing [FileColumns.MEDIA_TYPE_DOCUMENT] + */ + DOCUMENT(6); + + companion object { + /** + * Returns the matching [FileType] enum given an int value + * + * @param value int value of the [FileType] as written in [FileColumns.MEDIA_TYPE] column + */ + fun getEnum(value: Int) = values().find { + it.value == value + } ?: throw IllegalArgumentException("Unknown MediaStoreType value") + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FileUtils.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FileUtils.kt new file mode 100644 index 00000000..85e18c0a --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/FileUtils.kt @@ -0,0 +1,31 @@ +package com.samples.storage.scopedstorage.common + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.security.MessageDigest + +/** + * Number of bytes to read at a time from an open stream + */ +private const val FILE_BUFFER_SIZE_BYTES = 1024 + +object FileUtils { + suspend fun getInputStreamChecksum(inputStream: InputStream): String { + return withContext(Dispatchers.IO) { + inputStream.use { stream -> + val messageDigest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(FILE_BUFFER_SIZE_BYTES) + var bytesRead = stream.read(buffer) + + while (bytesRead > 0) { + messageDigest.update(buffer, 0, bytesRead) + bytesRead = stream.read(buffer) + } + + val hashResult = messageDigest.digest() + return@withContext hashResult.joinToString("") { "%02x".format(it) } + } + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/ImageUtils.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/ImageUtils.kt new file mode 100644 index 00000000..2fb5b072 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/ImageUtils.kt @@ -0,0 +1,21 @@ +package com.samples.storage.scopedstorage.common + +import android.graphics.Bitmap +import android.graphics.Color +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.random.Random + +object ImageUtils { + fun getRandomColor(): Int { + return Color.argb(255, Random.nextInt(256), Random.nextInt(256), Random.nextInt(256)) + } + + suspend fun generateImage(color: Int, width: Int, height: Int): Bitmap { + return withContext(Dispatchers.Default) { + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.eraseColor(color) + return@withContext bitmap + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/IntroCard.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/IntroCard.kt new file mode 100644 index 00000000..f1da2f01 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/IntroCard.kt @@ -0,0 +1,39 @@ +package com.samples.storage.scopedstorage.mediastore + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Card +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.samples.storage.scopedstorage.R +import com.samples.storage.scopedstorage.common.compositeBorderColor + +@Composable +fun IntroCard() { + Card( + elevation = 0.dp, + border = BorderStroke(width = 1.dp, color = compositeBorderColor()), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.empty_state_label), + style = MaterialTheme.typography.subtitle2 + ) + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/MediaStoreActivityResults.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/MediaStoreActivityResults.kt new file mode 100644 index 00000000..60b8b644 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/MediaStoreActivityResults.kt @@ -0,0 +1,49 @@ +package com.samples.storage.scopedstorage.common + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts + +/** + * An [ActivityResultContract] to request the modification of a media [Uri] + * + * @return true if the [Uri] is modifiable, else the user denied the user denied the request (from API 30+) + */ +class ModifyMediaRequest : ActivityResultContract() { + + override fun createIntent(context: Context, input: Uri): Intent { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Intent(ActivityResultContracts.StartIntentSenderForResult.ACTION_INTENT_SENDER_REQUEST) + .putExtra( + ActivityResultContracts.StartIntentSenderForResult.EXTRA_INTENT_SENDER_REQUEST, + IntentSenderRequest.Builder( + MediaStore.createWriteRequest( + context.contentResolver, + listOf(input) + ).intentSender + ).build() + ) + } else { + Intent() + } + } + + override fun getSynchronousResult(context: Context, input: Uri): SynchronousResult? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + null + } else { + SynchronousResult(input) + } + } + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? { + return null +// return resultCode == Activity.RESULT_OK + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/MediaStoreUtils.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/MediaStoreUtils.kt new file mode 100644 index 00000000..1d8329ce --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/MediaStoreUtils.kt @@ -0,0 +1,286 @@ +package com.samples.storage.scopedstorage.common + +import android.Manifest.permission.READ_EXTERNAL_STORAGE +import android.Manifest.permission.WRITE_EXTERNAL_STORAGE +import android.content.ContentUris +import android.content.ContentValues +import android.content.Context +import android.content.pm.PackageManager +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.provider.MediaStore.MediaColumns.DATE_ADDED +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat.checkSelfPermission +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +object MediaStoreUtils { + /** + * Check if the app can read the shared storage + */ + fun canReadInMediaStore(context: Context) = + checkSelfPermission(context, READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED + + /** + * Check if the app can writes on the shared storage + * + * On Android 10 (API 29), we can add media to MediaStore without having to request the + * [WRITE_EXTERNAL_STORAGE] permission, so we only check on pre-API 29 devices + */ + fun canWriteInMediaStore(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + true + } else { + checkSelfPermission( + context, + WRITE_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } + } + + /** + * We create a MediaStore [Uri] where an image will be stored + */ + suspend fun createImageUri(context: Context, filename: String): Uri? { + val imageCollection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + + return withContext(Dispatchers.IO) { + val newImage = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, filename) + } + + // This method will perform a binder transaction which is better to execute off the main + // thread + return@withContext context.contentResolver.insert(imageCollection, newImage) + } + } + + /** + * We create a MediaStore [Uri] where a video will be stored + */ + suspend fun createVideoUri(context: Context, filename: String): Uri? { + val videoCollection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + } else { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } + + return withContext(Dispatchers.IO) { + val newVideo = ContentValues().apply { + put(MediaStore.Video.Media.DISPLAY_NAME, filename) + } + + // This method will perform a binder transaction which is better to execute off the main + // thread + return@withContext context.contentResolver.insert(videoCollection, newVideo) + } + } + + /** + * We create a MediaStore [Uri] where an image will be stored + */ + @RequiresApi(Build.VERSION_CODES.Q) + suspend fun createDownloadUri(context: Context, filename: String): Uri? { + val downloadsCollection = + MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + + return withContext(Dispatchers.IO) { + val newImage = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, filename) + } + + // This method will perform a binder transaction which is better to execute off the main + // thread + return@withContext context.contentResolver.insert(downloadsCollection, newImage) + } + } + + /** + * Convert a media [Uri] to a content [Uri] to be used when requesting + * [MediaStore.Files.FileColumns] values. + * + * Some columns are only available on the [MediaStore.Files] collection and this method converts + * [Uri] from other MediaStore collections (e.g. [MediaStore.Images]) + * + * @param uri [Uri] representing the MediaStore entry. + */ + private fun convertMediaUriToContentUri(uri: Uri): Uri? { + val entryId = uri.lastPathSegment ?: return null + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + MediaStore.Files.getContentUri(MediaStore.getVolumeName(uri), entryId.toLong()) + } else { + MediaStore.Files.getContentUri(uri.pathSegments[0], entryId.toLong()) + } + } + + suspend fun scanPath(context: Context, path: String, mimeType: String): Uri? { + return suspendCancellableCoroutine { continuation -> + MediaScannerConnection.scanFile( + context, + arrayOf(path), + arrayOf(mimeType) + ) { _, scannedUri -> + if (scannedUri == null) { + continuation.cancel(Exception("File $path could not be scanned")) + } else { + continuation.resume(scannedUri) + } + } + } + } + + suspend fun scanUri(context: Context, uri: Uri, mimeType: String): Uri? { + val cursor = context.contentResolver.query( + uri, + arrayOf(MediaStore.Files.FileColumns.DATA), + null, + null, + null + ) ?: throw Exception("Uri $uri could not be found") + + val path = cursor.use { + if (!cursor.moveToFirst()) { + throw Exception("Uri $uri could not be found") + } + + cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATA)) + } + + return suspendCancellableCoroutine { continuation -> + MediaScannerConnection.scanFile( + context, + arrayOf(path), + arrayOf(mimeType) + ) { _, scannedUri -> + if (scannedUri == null) { + continuation.cancel(Exception("File $path could not be scanned")) + } else { + continuation.resume(scannedUri) + } + } + } + } + + /** + * Returns a [FileResource] if it finds its [Uri] in MediaStore. + */ + suspend fun getResourceByUri(context: Context, uri: Uri): FileResource { + return withContext(Dispatchers.IO) { + // Convert generic media uri to content uri to get FileColumns.MEDIA_TYPE value + val contentUri = convertMediaUriToContentUri(uri) + + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.SIZE, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.DATA, + ) + + val cursor = contentUri?.let { + context.contentResolver.query( + it, + projection, + null, + null, + null + ) + } ?: throw Exception("Uri $uri could not be found") + + cursor.use { + if (!cursor.moveToFirst()) { + throw Exception("Uri $uri could not be found") + } + + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val displayNameColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE) + val mediaTypeColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val mimeTypeColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE) + val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATA) + + FileResource( + uri = contentUri, + filename = cursor.getString(displayNameColumn), + size = cursor.getLong(sizeColumn), + type = FileType.getEnum(cursor.getInt(mediaTypeColumn)), + mimeType = cursor.getString(mimeTypeColumn), + path = cursor.getString(dataColumn), + ) + } + } + } + + + /** + * Returns a [FileResource] if it finds its [Uri] in MediaStore. + */ + suspend fun getMediaResources(context: Context, limit: Int = 50): List { + return withContext(Dispatchers.IO) { + val mediaList = mutableListOf() + val externalContentUri = MediaStore.Files.getContentUri("external") + ?: throw Exception("External Storage not available") + + val projection = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.DISPLAY_NAME, + MediaStore.Files.FileColumns.SIZE, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.DATA, + ) + + val cursor = context.contentResolver.query( + externalContentUri, + projection, + null, + null, + "$DATE_ADDED DESC" + ) ?: throw Exception("Query could not be executed") + + cursor.use { + while (cursor.moveToNext() && mediaList.size < limit) { + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val displayNameColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.SIZE) + val mediaTypeColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MEDIA_TYPE) + val mimeTypeColumn = + cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.MIME_TYPE) + val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATA) + + val id = cursor.getInt(idColumn) + val contentUri: Uri = ContentUris.withAppendedId( + externalContentUri, + id.toLong() + ) + + mediaList += FileResource( + uri = contentUri, + filename = cursor.getString(displayNameColumn), + size = cursor.getLong(sizeColumn), + type = FileType.getEnum(cursor.getInt(mediaTypeColumn)), + mimeType = cursor.getString(mimeTypeColumn), + path = cursor.getString(dataColumn), + ) + } + } + + return@withContext mediaList + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/SafUtils.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/SafUtils.kt new file mode 100644 index 00000000..c85fd600 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/SafUtils.kt @@ -0,0 +1,66 @@ +package com.samples.storage.scopedstorage.common + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Point +import android.net.Uri +import android.provider.DocumentsContract +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +object SafUtils { + /** + * Returns a [FileResource] if it finds its related DocumentsProvider + */ + suspend fun getResourceByUri(context: Context, uri: Uri): FileResource { + return withContext(Dispatchers.IO) { + + val projection = arrayOf( + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_SIZE, + DocumentsContract.Document.COLUMN_MIME_TYPE, + ) + + val cursor = context.contentResolver.query( + uri, + projection, + null, + null, + null + ) ?: throw Exception("Uri $uri could not be found") + + cursor.use { + if (!cursor.moveToFirst()) { + throw Exception("Uri $uri could not be found") + } + + val displayNameColumn = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val sizeColumn = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) + val mimeTypeColumn = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + + FileResource( + uri = uri, + filename = cursor.getString(displayNameColumn), + size = cursor.getLong(sizeColumn), + type = FileType.DOCUMENT, + mimeType = cursor.getString(mimeTypeColumn), + path = null, + ) + } + } + } + + suspend fun getThumbnail(context: Context, uri: Uri): Bitmap? { + return withContext(Dispatchers.IO) { + return@withContext DocumentsContract.getDocumentThumbnail( + context.contentResolver, + uri, + Point(512, 512), + null + ) + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/UiUtils.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/UiUtils.kt new file mode 100644 index 00000000..1137bffc --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/common/UiUtils.kt @@ -0,0 +1,17 @@ +package com.samples.storage.scopedstorage.common + +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.unit.dp + +/** + * Composite of local content color at 12% alpha over background color, used by borders. + */ +@Composable +fun compositeBorderColor(): Color = LocalContentColor.current.copy(alpha = BorderAlpha) + .compositeOver(MaterialTheme.colors.background) + +private const val BorderAlpha = 0.12f \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddFileToDownloadsScreen.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddFileToDownloadsScreen.kt new file mode 100644 index 00000000..b795a93c --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddFileToDownloadsScreen.kt @@ -0,0 +1,89 @@ +package com.samples.storage.scopedstorage.mediastore + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.samples.storage.scopedstorage.Demos +import com.samples.storage.scopedstorage.HomeRoute +import com.samples.storage.scopedstorage.R +import com.samples.storage.scopedstorage.common.DocumentFilePreviewCard +import com.samples.storage.scopedstorage.mediastore.AddFileToDownloadsViewModel.FileType + +@ExperimentalFoundationApi +@Composable +fun AddFileToDownloadsScreen( + navController: NavController, + viewModel: AddFileToDownloadsViewModel = viewModel() +) { + val scaffoldState = rememberScaffoldState() + val error by viewModel.errorFlow.collectAsState(null) + val addedMedia by viewModel.addedFile.observeAsState() + + LaunchedEffect(error) { + error?.let { scaffoldState.snackbarHostState.showSnackbar(it) } + } + + Scaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + title = { Text(stringResource(Demos.AddFileToDownloads.name)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack(HomeRoute, false) }) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.back_button_label) + ) + } + } + ) + }, + content = { paddingValues -> + Column(Modifier.padding(paddingValues)) { + if (addedMedia != null) { + DocumentFilePreviewCard(addedMedia!!) + } else { + IntroCard() + } + + LazyVerticalGrid(cells = GridCells.Fixed(2)) { + item { + Button( + modifier = Modifier.padding(16.dp), + onClick = { viewModel.addFile(FileType.Pdf) }) { + Text(stringResource(R.string.demo_add_pdf_label)) + } + } + item { + Button( + modifier = Modifier.padding(16.dp), + onClick = { viewModel.addFile(FileType.Zip) }) { + Text(stringResource(R.string.demo_add_zip_label)) + } + } + } + } + } + ) +} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddFileToDownloadsViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddFileToDownloadsViewModel.kt new file mode 100644 index 00000000..f952f6bb --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddFileToDownloadsViewModel.kt @@ -0,0 +1,108 @@ +package com.samples.storage.scopedstorage.mediastore + +import android.app.Application +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.os.Environment.DIRECTORY_DOWNLOADS +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.samples.storage.scopedstorage.common.FileResource +import com.samples.storage.scopedstorage.common.MediaStoreUtils +import com.samples.storage.scopedstorage.common.MediaStoreUtils.scanPath +import com.samples.storage.scopedstorage.common.MediaStoreUtils.scanUri +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException + +class AddFileToDownloadsViewModel( + application: Application, + private val savedStateHandle: SavedStateHandle +) : AndroidViewModel(application) { + + companion object { + private val TAG = this::class.java.simpleName + const val ADDED_FILE_KEY = "addedFile" + } + + private val context: Context + get() = getApplication() + + val canWriteInMediaStore: Boolean + get() = MediaStoreUtils.canWriteInMediaStore(context) + + private val _errorFlow = MutableSharedFlow() + val errorFlow: SharedFlow = _errorFlow + + /** + * We keep the current media [Uri] in the savedStateHandle to re-render it if there is a + * configuration change and we expose it as a [LiveData] to the UI + */ + val addedFile: LiveData = + savedStateHandle.getLiveData(ADDED_FILE_KEY) + + sealed class FileType(val extension: String, val mimeType: String) { + object Pdf : FileType("pdf", "application/pdf") + object Zip : FileType("zip", "application/zip") + } + + private fun generateFilename(extension: String) = + "added-${System.currentTimeMillis()}.$extension" + + fun addFile(type: FileType) { + viewModelScope.launch { + var filename = generateFilename(type.extension) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val uri = MediaStoreUtils.createDownloadUri(context, filename) + ?: return@launch _errorFlow.emit("Couldn't create a ${type.extension} Uri\n$filename") + + try { + context.contentResolver.openOutputStream(uri, "w")?.use { outputStream -> + context.assets.open("sample.${type.extension}").use { inputStream -> + inputStream.copyTo(outputStream) + scanUri(context, uri, type.mimeType) + savedStateHandle[ADDED_FILE_KEY] = + MediaStoreUtils.getResourceByUri(context, uri) + } + } + + } catch (e: IOException) { + Log.e(TAG, e.printStackTrace().toString()) + _errorFlow.emit("Couldn't save the ${type.extension}\n$uri") + } + } else { + val downloadsFolder = + Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS) + + // We re-generate the filename until we can confirm its uniqueness + while (File(downloadsFolder, filename).exists()) { + filename = generateFilename(type.extension) + } + + val file = File(downloadsFolder, filename) + + try { + file.outputStream().use { outputStream -> + context.assets.open("sample.${type.extension}").use { inputStream -> + inputStream.copyTo(outputStream) + scanPath(context, file.absolutePath, type.mimeType)?.let { uri -> + savedStateHandle[ADDED_FILE_KEY] = + MediaStoreUtils.getResourceByUri(context, uri) + } + } + } + } catch (e: IOException) { + Log.e(TAG, e.printStackTrace().toString()) + _errorFlow.emit("Couldn't save the ${type.extension}\n${file.absolutePath}") + } + } + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddMediaFileScreen.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddMediaFileScreen.kt new file mode 100644 index 00000000..be93938f --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddMediaFileScreen.kt @@ -0,0 +1,88 @@ +package com.samples.storage.scopedstorage.mediastore + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.samples.storage.scopedstorage.Demos +import com.samples.storage.scopedstorage.HomeRoute +import com.samples.storage.scopedstorage.R +import com.samples.storage.scopedstorage.common.MediaFilePreviewCard + +@ExperimentalFoundationApi +@Composable +fun AddMediaFileScreen( + navController: NavController, + viewModel: AddMediaFileViewModel = viewModel() +) { + val scaffoldState = rememberScaffoldState() + val error by viewModel.errorFlow.collectAsState(null) + val addedMedia by viewModel.addedMedia.observeAsState() + + LaunchedEffect(error) { + error?.let { scaffoldState.snackbarHostState.showSnackbar(it) } + } + + Scaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + title = { Text(stringResource(Demos.AddMediaFile.name)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack(HomeRoute, false) }) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.back_button_label) + ) + } + } + ) + }, + content = { paddingValues -> + Column(Modifier.padding(paddingValues)) { + if (addedMedia != null) { + MediaFilePreviewCard(addedMedia!!) + } else { + IntroCard() + } + + LazyVerticalGrid(cells = GridCells.Fixed(2)) { + item { + Button( + modifier = Modifier.padding(16.dp), + onClick = { viewModel.addImage() }) { + Text(stringResource(R.string.demo_add_image_label)) + } + } + item { + Button( + modifier = Modifier.padding(16.dp), + onClick = { viewModel.addVideo() }) { + Text(stringResource(R.string.demo_add_video_label)) + } + } + } + } + } + ) +} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddMediaFileViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddMediaFileViewModel.kt new file mode 100644 index 00000000..b602d784 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/AddMediaFileViewModel.kt @@ -0,0 +1,92 @@ +package com.samples.storage.scopedstorage.mediastore + +import android.app.Application +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.samples.storage.scopedstorage.common.FileResource +import com.samples.storage.scopedstorage.common.MediaStoreUtils +import com.samples.storage.scopedstorage.common.MediaStoreUtils.scanUri +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch +import java.io.IOException + +class AddMediaFileViewModel( + application: Application, + private val savedStateHandle: SavedStateHandle +) : AndroidViewModel(application) { + + companion object { + private val TAG = this::class.java.simpleName + const val ADDED_MEDIA_KEY = "addedMedia" + } + + private val context: Context + get() = getApplication() + + val canWriteInMediaStore: Boolean + get() = MediaStoreUtils.canWriteInMediaStore(context) + + private val _errorFlow = MutableSharedFlow() + val errorFlow: SharedFlow = _errorFlow + + /** + * We keep the current media [Uri] in the savedStateHandle to re-render it if there is a + * configuration change and we expose it as a [LiveData] to the UI + */ + val addedMedia: LiveData = + savedStateHandle.getLiveData(ADDED_MEDIA_KEY) + + fun addImage() { + viewModelScope.launch { + val filename = "added-${System.currentTimeMillis()}.jpg" + + val uri = MediaStoreUtils.createImageUri(context, filename) + ?: return@launch _errorFlow.emit("Couldn't create an image Uri\n$filename") + + try { + context.contentResolver.openOutputStream(uri, "w")?.use { outputStream -> + context.assets.open("sample.jpg").use { inputStream -> + inputStream.copyTo(outputStream) + scanUri(context, uri, "image/jpg") + savedStateHandle[ADDED_MEDIA_KEY] = + MediaStoreUtils.getResourceByUri(context, uri) + } + } + + } catch (e: IOException) { + Log.e(TAG, e.printStackTrace().toString()) + _errorFlow.emit("Couldn't save the image\n$uri") + } + } + } + + fun addVideo() { + viewModelScope.launch { + val filename = "added-${System.currentTimeMillis()}.mp4" + + val uri = MediaStoreUtils.createVideoUri(context, filename) + ?: return@launch _errorFlow.emit("Couldn't create an video Uri\n$filename") + + try { + context.contentResolver.openOutputStream(uri, "w")?.use { outputStream -> + context.assets.open("sample.mp4").use { inputStream -> + inputStream.copyTo(outputStream) + scanUri(context, uri, "video/mp4") + savedStateHandle[ADDED_MEDIA_KEY] = + MediaStoreUtils.getResourceByUri(context, uri) + } + } + + } catch (e: IOException) { + Log.e(TAG, e.printStackTrace().toString()) + _errorFlow.emit("Couldn't save the video\n$uri") + } + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/CaptureMediaFileScreen.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/CaptureMediaFileScreen.kt new file mode 100644 index 00000000..6b6d91d3 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/CaptureMediaFileScreen.kt @@ -0,0 +1,130 @@ +package com.samples.storage.scopedstorage.mediastore + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.samples.storage.scopedstorage.Demos +import com.samples.storage.scopedstorage.HomeRoute +import com.samples.storage.scopedstorage.R +import com.samples.storage.scopedstorage.common.MediaFilePreviewCard +import kotlinx.coroutines.launch + +@ExperimentalFoundationApi +@Composable +fun CaptureMediaFileScreen( + navController: NavController, + viewModel: CaptureMediaFileViewModel = viewModel() +) { + val scaffoldState = rememberScaffoldState() + val error by viewModel.errorFlow.collectAsState(null) + val capturedMedia by viewModel.capturedMedia.observeAsState() + + val scope = rememberCoroutineScope() + var targetImageUri by rememberSaveable { mutableStateOf(null) } + var targetVideoUri by rememberSaveable { mutableStateOf(null) } + + val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { + targetImageUri?.let { + viewModel.onImageCapture(it) + targetImageUri = null + } + } + val takeVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo()) { + targetVideoUri?.let { + viewModel.onVideoCapture(it) + targetVideoUri = null + } + } + + + LaunchedEffect(error) { + error?.let { scaffoldState.snackbarHostState.showSnackbar(it) } + } + + Scaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + title = { Text(stringResource(Demos.CaptureMediaFile.name)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack(HomeRoute, false) }) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.back_button_label) + ) + } + } + ) + }, + content = { paddingValues -> + Column(Modifier.padding(paddingValues)) { + if (capturedMedia != null) { + MediaFilePreviewCard(capturedMedia!!) + } else { + IntroCard() + } + + LazyVerticalGrid(cells = GridCells.Fixed(2)) { + item { + Button( + modifier = Modifier.padding(16.dp), + onClick = { + scope.launch { + viewModel.createImageUri()?.let { + targetImageUri = it + takePicture.launch(it) + } + } + } + ) { + Text(stringResource(R.string.demo_capture_image_label)) + } + } + item { + Button( + modifier = Modifier.padding(16.dp), + onClick = { + scope.launch { + viewModel.createVideoUri()?.let { + targetVideoUri = it + takeVideo.launch(it) + } + } + } + ) { + Text(stringResource(R.string.demo_capture_video_label)) + } + } + } + } + } + ) +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/CaptureMediaFileViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/CaptureMediaFileViewModel.kt new file mode 100644 index 00000000..41fceccd --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/CaptureMediaFileViewModel.kt @@ -0,0 +1,79 @@ +package com.samples.storage.scopedstorage.mediastore + +import android.app.Application +import android.content.Context +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.samples.storage.scopedstorage.common.FileResource +import com.samples.storage.scopedstorage.common.MediaStoreUtils +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +class CaptureMediaFileViewModel( + application: Application, + private val savedStateHandle: SavedStateHandle +) : AndroidViewModel(application) { + + companion object { + private val TAG = this::class.java.simpleName + const val CAPTURED_MEDIA_KEY = "capturedMedia" + } + + private val context: Context + get() = getApplication() + + val canWriteInMediaStore: Boolean + get() = MediaStoreUtils.canWriteInMediaStore(context) + + private val _errorFlow = MutableSharedFlow() + val errorFlow: SharedFlow = _errorFlow + + /** + * We keep the current media [Uri] in the savedStateHandle to re-render it if there is a + * configuration change and we expose it as a [LiveData] to the UI + */ + val capturedMedia: LiveData = + savedStateHandle.getLiveData(CAPTURED_MEDIA_KEY) + + suspend fun createImageUri(): Uri? { + val filename = "camera-${System.currentTimeMillis()}.jpg" + val uri = MediaStoreUtils.createImageUri(context, filename) + + return if (uri != null) { + uri + } else { + _errorFlow.emit("Couldn't create an image Uri\n$filename") + null + } + } + + suspend fun createVideoUri(): Uri? { + val filename = "camera-${System.currentTimeMillis()}.mp4" + val uri = MediaStoreUtils.createVideoUri(context, filename) + + return if (uri != null) { + uri + } else { + _errorFlow.emit("Couldn't create a video Uri\n$filename") + null + } + } + + fun onImageCapture(uri: Uri) { + viewModelScope.launch { + MediaStoreUtils.scanUri(context, uri, "image/jpg") + savedStateHandle[CAPTURED_MEDIA_KEY] = MediaStoreUtils.getResourceByUri(context, uri) + } + } + + fun onVideoCapture(uri: Uri) { + viewModelScope.launch { + MediaStoreUtils.scanUri(context, uri, "video/mp4") + savedStateHandle[CAPTURED_MEDIA_KEY] = MediaStoreUtils.getResourceByUri(context, uri) + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/ListMediaFilesScreen.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/ListMediaFilesScreen.kt new file mode 100644 index 00000000..aced99de --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/ListMediaFilesScreen.kt @@ -0,0 +1,127 @@ +package com.samples.storage.scopedstorage.mediastore + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.samples.storage.scopedstorage.Demos +import com.samples.storage.scopedstorage.HomeRoute +import com.samples.storage.scopedstorage.R +import com.skydoves.landscapist.glide.GlideImage + + +@ExperimentalFoundationApi +@Composable +fun ListMediaFileScreen( + navController: NavController, + viewModel: ListMediaFilesViewModel = viewModel() +) { + val scaffoldState = rememberScaffoldState() + val error by viewModel.errorFlow.collectAsState(null) + val mediaQuery by viewModel.mediaQuery.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadMedia() + } + + LaunchedEffect(error) { + error?.let { scaffoldState.snackbarHostState.showSnackbar(it) } + } + + Scaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + title = { Text(stringResource(Demos.ListMediaFiles.name)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack(HomeRoute, false) }) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.back_button_label) + ) + } + } + ) + }, + content = { paddingValues -> + Column(Modifier.padding(paddingValues)) { + if (mediaQuery.loading) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.demo_list_media_loading)) + } + } else { + if (!mediaQuery.success) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.demo_list_media_failure)) + } + } else { + if (mediaQuery.results.isEmpty()) { + Column( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(stringResource(R.string.demo_list_media_no_files_found)) + } + } else { + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + cells = GridCells.Fixed(4) + ) { + items(mediaQuery.results) { resource -> + Box( + Modifier + .aspectRatio(1f) + .padding(2.dp) + .background(Color.LightGray) + ) { + GlideImage( + imageModel = resource.uri, + contentScale = ContentScale.Crop, + contentDescription = null + ) + } + } + } + } + } + } + } + } + ) +} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/ListMediaFilesViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/ListMediaFilesViewModel.kt new file mode 100644 index 00000000..8f141faa --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/mediastore/ListMediaFilesViewModel.kt @@ -0,0 +1,58 @@ +package com.samples.storage.scopedstorage.mediastore + +import android.app.Application +import android.content.Context +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.samples.storage.scopedstorage.common.FileResource +import com.samples.storage.scopedstorage.common.MediaStoreUtils +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class ListMediaFilesViewModel(application: Application) : AndroidViewModel(application) { + + companion object { + private val TAG = this::class.java.simpleName + } + + private val context: Context + get() = getApplication() + + val canReadInMediaStore: Boolean + get() = MediaStoreUtils.canReadInMediaStore(context) + + private val _errorFlow = MutableSharedFlow() + val errorFlow: SharedFlow = _errorFlow + + class MediaQuery( + val loading: Boolean, + val success: Boolean, + val results: List = emptyList() + ) + + private val _mediaQuery = MutableStateFlow(MediaQuery(loading = true, success = false)) + val mediaQuery = _mediaQuery.asStateFlow() + + fun loadMedia() { + viewModelScope.launch { + _mediaQuery.value = MediaQuery(loading = true, success = false) + + // Simulate delay + delay(1000L) + + try { + val results = MediaStoreUtils.getMediaResources(context, limit = 10) + _mediaQuery.value = MediaQuery(loading = false, success = true, results = results) + } catch (e: Exception) { + Log.e(TAG, e.printStackTrace().toString()) + _mediaQuery.value = MediaQuery(loading = false, success = false) + _errorFlow.emit("Couldn't query MediaStore") + } + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/saf/SelectDocumentFileScreen.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/saf/SelectDocumentFileScreen.kt new file mode 100644 index 00000000..bd25c6d1 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/saf/SelectDocumentFileScreen.kt @@ -0,0 +1,107 @@ +package com.samples.storage.scopedstorage.saf + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.GridCells +import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.samples.storage.scopedstorage.Demos +import com.samples.storage.scopedstorage.HomeRoute +import com.samples.storage.scopedstorage.R +import com.samples.storage.scopedstorage.common.DocumentFilePreviewCard +import com.samples.storage.scopedstorage.mediastore.IntroCard + +const val GENERIC_MIMETYPE = "*/*" +const val PDF_MIMETYPE = "application/pdf" +const val ZIP_MIMETYPE = "application/zip" + +@ExperimentalFoundationApi +@Composable +fun SelectDocumentFileScreen( + navController: NavController, + viewModel: SelectDocumentFileViewModel = viewModel() +) { + val scaffoldState = rememberScaffoldState() + val error by viewModel.errorFlow.collectAsState(null) + val selectedFile by viewModel.selectedFile.observeAsState() + + val selectFile = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { viewModel.onFileSelect(it) } + } + + LaunchedEffect(error) { + error?.let { scaffoldState.snackbarHostState.showSnackbar(it) } + } + + Scaffold( + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + title = { Text(stringResource(Demos.SelectDocumentFile.name)) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack(HomeRoute, false) }) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = stringResource(R.string.back_button_label) + ) + } + } + ) + }, + content = { paddingValues -> + Column(Modifier.padding(paddingValues)) { + if (selectedFile != null) { + DocumentFilePreviewCard(selectedFile!!) + } else { + IntroCard() + } + + LazyVerticalGrid(cells = GridCells.Fixed(1)) { + item { + Button( + modifier = Modifier.padding(4.dp), + onClick = { selectFile.launch(arrayOf(GENERIC_MIMETYPE)) }) { + Text(stringResource(R.string.demo_select_any_document)) + } + } + item { + Button( + modifier = Modifier.padding(4.dp), + onClick = { selectFile.launch(arrayOf(PDF_MIMETYPE)) }) { + Text(stringResource(R.string.demo_select_pdf_document)) + } + } + item { + Button( + modifier = Modifier.padding(4.dp), + onClick = { selectFile.launch(arrayOf(ZIP_MIMETYPE)) }) { + Text(stringResource(R.string.demo_select_zip_document)) + } + } + } + } + } + ) +} diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/saf/SelectDocumentFileViewModel.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/saf/SelectDocumentFileViewModel.kt new file mode 100644 index 00000000..d3a6cadf --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/saf/SelectDocumentFileViewModel.kt @@ -0,0 +1,54 @@ +package com.samples.storage.scopedstorage.saf + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.samples.storage.scopedstorage.common.FileResource +import com.samples.storage.scopedstorage.common.SafUtils +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.launch + +class SelectDocumentFileViewModel( + application: Application, + private val savedStateHandle: SavedStateHandle +) : AndroidViewModel(application) { + + companion object { + private val TAG = this::class.java.simpleName + const val SELECTED_FILE_KEY = "selectedFile" + } + + private val context: Context + get() = getApplication() + + private val _errorFlow = MutableSharedFlow() + val errorFlow: SharedFlow = _errorFlow + + /** + * We keep the current media [Uri] in the savedStateHandle to re-render it if there is a + * configuration change and we expose it as a [LiveData] to the UI + */ + val selectedFile: LiveData = + savedStateHandle.getLiveData(SELECTED_FILE_KEY) + + @SuppressLint("NewApi") + fun onFileSelect(uri: Uri) { + viewModelScope.launch { + savedStateHandle[SELECTED_FILE_KEY] = SafUtils.getResourceByUri(context, uri) + + try { + savedStateHandle[SELECTED_FILE_KEY] = SafUtils.getResourceByUri(context, uri) + } catch (e: Exception) { + Log.e(TAG, e.printStackTrace().toString()) + _errorFlow.emit("Couldn't load $uri") + } + } + } +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Color.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Color.kt new file mode 100644 index 00000000..ce5bd615 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Color.kt @@ -0,0 +1,8 @@ +package com.samples.storage.scopedstorage.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Shape.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Shape.kt new file mode 100644 index 00000000..64615038 --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Shape.kt @@ -0,0 +1,11 @@ +package com.samples.storage.scopedstorage.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp) +) \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Theme.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Theme.kt new file mode 100644 index 00000000..ce3b7ecc --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Theme.kt @@ -0,0 +1,47 @@ +package com.samples.storage.scopedstorage.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable + +private val DarkColorPalette = darkColors( + primary = Purple200, + primaryVariant = Purple700, + secondary = Teal200 +) + +private val LightColorPalette = lightColors( + primary = Purple500, + primaryVariant = Purple700, + secondary = Teal200 + + /* Other default colors to override + background = Color.White, + surface = Color.White, + onPrimary = Color.White, + onSecondary = Color.Black, + onBackground = Color.Black, + onSurface = Color.Black, + */ +) + +@Composable +fun ScopedStorageTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable() () -> Unit +) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme( + colors = colors, + typography = Typography, + shapes = Shapes, + content = content + ) +} \ No newline at end of file diff --git a/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Type.kt b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Type.kt new file mode 100644 index 00000000..abb6bf3f --- /dev/null +++ b/ScopedStorage/app/src/main/java/com/samples/storage/scopedstorage/ui/theme/Type.kt @@ -0,0 +1,28 @@ +package com.samples.storage.scopedstorage.ui.theme + +import androidx.compose.material.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + body1 = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp + ) + /* Other default text styles to override + button = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 14.sp + ), + caption = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ) + */ +) \ No newline at end of file diff --git a/ScopedStorage/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/ScopedStorage/app/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/ScopedStorage/app/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/ScopedStorage/app/src/main/res/drawable/ic_launcher_background.xml b/ScopedStorage/app/src/main/res/drawable/ic_launcher_background.xml index ca3826a4..07d5da9c 100644 --- a/ScopedStorage/app/src/main/res/drawable/ic_launcher_background.xml +++ b/ScopedStorage/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,74 +1,170 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:viewportHeight="108"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ScopedStorage/app/src/main/res/drawable/ic_launcher_foreground.xml b/ScopedStorage/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 4535491a..00000000 --- a/ScopedStorage/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - diff --git a/ScopedStorage/app/src/main/res/layout/fragment_add_document.xml b/ScopedStorage/app/src/main/res/layout/fragment_add_document.xml deleted file mode 100644 index 86a38ef4..00000000 --- a/ScopedStorage/app/src/main/res/layout/fragment_add_document.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - -