Scoped Storage في الأندرويد

Scoped Storage في الأندرويد

AbdulAlim Rajjoubمنذ سنتين

TL;DR

 

بدءاً من Android 10 قام فريق Android بتقديم متطلبات جديدة أو تغييرات جديدة بالنسبة للتعامل مع الملفات في نظام الأندرويد وقاموا بتقديم بما يعرف ب "Scoped Storage" الذي بدوره يمنع التطبيقات من قراءة ملفات التطبيقات الأخرى بحيث يكون كل تطبيق عبارة عن Container او صندوق. وفي نفس الوقت قاموا بإتاحة بعض APIs للتعامل مع الصور وبعض الملفات الذي يمكن مشاركتها والتي سنراها لاحقاً في هذا المقال.

لكن عندما قاموا بإنشاء Scoped Storage المطورين لم يكونو جاهزين بعد لتبني هذه التغييرات في تطبيقاتهم, ولهذا أتاحوا خيار الخروج من هذا الخيار(سنراه لاحقاً) اذا كان التطبيق Targets SDK 29 Android 10, ولكن هذا الخيار لم يعد يعمل اذا كان تطبيقك Targets SDK 30 Android 11.

 

كتابة الملفات على الذاكرة الداخلية:

هذه الذاكرة هي خاصة بتطبيقك والتي لا يستطيع المستخدم الوصول اليها الا فيا حال كان لديك صلاحيات Root.

سنقوم بإنشاء ملف نص يحتوي على باسوورد وسنقوم بكتابته الى ذاكرة الهاتف.

بعد ذلك سنجد ان الملف تم انشائه بنجاح.

من الجدير بالذكر بأن الملفات التي تكتب على الذاكرة الداخلية Internal Storage يتم حذفها عندما يتم حذف التطبيق.

 

val password = "pwd"
val file = File(filesDir,"my_secret_pwd.txt")
file.writeText(password)

كتابة الملفات على الذاكرة الخارجية الخاصة بالتطبيق :

هذه الذاكرة عادةً تكون داخل مجلد sdcard/Android/data/your.package.name والتي أيضاً يتم حذفها عند حذف التطبيق

من الجدير بالذكر أيضاً بأن الملفات التي تكتب على الذاكرة الخارجية الخاصة بالتطبيق  يتم حذفها عندما يتم حذف التطبيق.

 val password = "pwd"
 val file = File(getExternalFilesDir(null),"my_secret_pwd.txt")
 file.writeText(password)

كتابة الملفات على الذاكرة الخارجية (Legacy) :

الآن لنجرب كتابة نفس الملف على الذاكرة الخارجية External Storage او الذاكرة التي يمكن مشاركتها مع بعض التطبيقات, سنقوم بالتجريب على Android 10 API 29 وسنرى ما الذي سيحصل

وكما نعلم بدءاً من Android 6 يجب الطلب من المستخدم الموافقة على الصلاحيات,لهذا ساستخدم مكتبة بسيطة لتسهيل الأمر والتي سيتم تنفيذ الاوامر داخل Lambda فقط في حال تم اعطاء الصلاحيات من قبل المستخدم

 

   private fun writeTextToFile() {
        askPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE){
            val password = "pwd"
            val file = File(Environment.getExternalStorageDirectory(), "my_secret_pwd.txt")
            file.writeText(password)
        }
    }

من الجدير بالذكر أن Environment.getExternalStorageDirectory() هي deprecated حيث أن فريق Android يريد منع التطبيقات من الكتابة على الذاكرة الداخلية.

نقوم بتشغيل التطبيق لنرى النتيجة

وسنرى انه تم قام بعمل Throw Exception بأن الصلاحيات لم يتم الموافقة عليه, مع العلم أن المستخدم قد وافق على اعطاء الصلاحيات, ولكن بما أن Target sdk version هو API 29 فإنه يتم تجاهل أي عملية لكتابة الملفات باستخدام Files API.

بالنسبة ل Android 10 API 29 يوجد حل لهذه المشكلة كالتالي:

قم بالذهاب الى Android Manifest وضع هذا Tag

android:requestLegacyExternalStorage="true"

 

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.devlomi.scopedstoragesample">

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:requestLegacyExternalStorage="true"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.ScopedStorageSample">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

نقوم بتشغيل التطبيق مرة أخرى وسنجد أنه تم إنشاء الملف بنجاح

ولكن, هذا الأمر او "Hack" يتم تفعيله فقط على Android 10 API29 , اذا تمت اضافته وكان الجهاز Android 11 API 30 او اعلى فإنه يتم تجاهله وسيقوم التطبيق بعمل Throw Exception.

ملاحظة: كل ماسبق يتم تطبيقه بالنسبة ل قراءة الملفات أيضاً.

 

إذاً ما الحل اذا أردنا كتابة بعض الملفات على الهاتف بدون أن يتم فقدانها عند ازالة التطبيق, كالصور والفيديو والملفات وغيرها؟

كتابة الملفات على الذاكرة الخارجية Scoped Storage :

لكتابة ملف ما على الذاكرة الخارجية بدون استخدام Legacy وبحيث يكون متوافق مع Android 11 فما فوق, يتيح لنا نظام الأندرويد كتابة الملفات الى المجلدات التالية فقط باستخدام Media Store API:

  • Downloads لكتابة أية ملفات txt,apk,pdf,الخ...
  • DCIM,Pictures,Movies لملفات الفيديو او الصور
  • Music,Notifications,Ringtones لملفات الموسيقى

من الجدير بالذكر أنه أثناء استخدام Media Store API في كتابة الملفات يمكننا تجاهل اضافة WRITE_EXTERNAL_STORAGE في AndroidManifest , ولهذا يمكننا إضافة الآتي في Android Manifest
 

<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />

بهذا الشكل قلنا ل Android Manifest أن صلاحيات كتابة الملفات هي مطلوبة فقط اذا كان API 28 او اقل Android 9  او اقل

لنجرب الآن كتابة الملف النصي باستخدام MediaStore API

 fun writeTextToFile() {
        val password = "pwd"
        val fileName = "my_secret_pwd.txt"

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

            val downloadsCollection =
                MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

            val contentValues = ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, fileName)
            }

            contentResolver.insert(downloadsCollection, contentValues)?.also { uri ->
                contentResolver.openOutputStream(uri)?.use { outputStream ->
                    outputStream.write(password.toByteArray())
                }
            }
        }

    }

لنشرح قليلاً الكود السابق, أولاً قمنا بالتأكد بأن الاصدار هو Android 10 او اعلى , ثم قمنا بإنشاء Collection 

ثم قمنا بتحديد Content Value وهي التي ستحدد اسم الملف,مساره, mime type, ومعلومات أخرى يمكنك استكشافها بنفسك, في حالتنا قمنا بتحديد اسم الملف فقط , ولاحظ أنه قمنا باختيار MediaStore.Downloads ليتم حفظ الملف في Downloads.

ثم قمنا باستدعاء ContentResolver.insert الذي سيقوم بأخذ هذه البيانات وإرساله للنظام ليتم إنشاء هذا الملف, وفي نفس الوقت يعطينا uri والذي يتعامل معه الجهاز, بحيث كل ملف,صورة,فيديو,الخ.. داخل قاعدة بيانات الهاتف له uri معين, يمكنك اعتباره ك ID.

ثم قمنا بفتح OutputStream لبدء فعلياً كتابة الملف, ثم قمنا بتحويل pwd string الى byteArray 

أخيراً نقوم بتشغيل التطبيق وسنرى النتيجة أنه تمت كتابة الملف داخل مجلد Downloads.

 

ماذا إذا أردنا ترتيب ملفاتنا بحيث يتم حفظ الملف داخل مجلد داخل مجلد Downloads , على سبيل المثال Downloads/MyFolder/file.txt ؟

الأمر بسيط جداً, كل ماعلينا هو تحديد RelativePath داخل ContentValues وفي حالتنا هو Downloads/OurFolderName 

  fun writeTextToFile() {
        val password = "pwd"
        val fileName = "my_secret_pwd.txt"

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

            val downloadsCollection =
                MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

            val contentValues = ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, fileName)
                put(
                    MediaStore.Audio.Media.RELATIVE_PATH,
                    "${Environment.DIRECTORY_DOWNLOADS}/Devlomi/"
                )
            }

            contentResolver.insert(downloadsCollection, contentValues)?.also { uri ->
                contentResolver.openOutputStream(uri)?.use { outputStream ->
                    outputStream.write(password.toByteArray())
                }
            }
        }

    }

لنقم بتشغيل التطبيق وسنرى أنه تم إنشاء الملف داخل مجلد Downloads وتم إنشاء مجلد جديد Devlomi

ماذا عن الصور او الفيديو؟

سنتبع نفس الطريقة باستخدام MediaStore API , لهذا سنقوم بتجريب أخذ صورة من المعرض وحفظها في الهاتف مرة أخرى :)

نقوم بتشغيل Intent وننتظر لحين قدوم النتيجة, ثم نتحقق اذا كانت النتيجة OK  ونأخذ Uri ونتأكد انه ليس null , وأخيراً نستدعي الميثود 

       val contract = ActivityResultContracts.StartActivityForResult()
        val launcher = registerForActivityResult(contract){result ->
            if (result.resultCode == RESULT_OK){
                result.data?.data?.let { imageUri -> 
                    saveImage(imageUri)
                }
            }
        }

        val photoPickerIntent = Intent(Intent.ACTION_PICK)
        photoPickerIntent.type = "image/*"
        launcher.launch(photoPickerIntent)

لنقم بإنشاء الميثود saveImage() 

وكما فعلنا عند حفظ ملف txt في Downloads سنطبق نفس الفكرة

    private fun saveImage(imageUri: Uri) {


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

            val picturesCollection =
                MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

            val contentValues = ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, "Wallpaper.jpg")

            }

            contentResolver.insert(picturesCollection, contentValues)?.also { uri ->
                contentResolver.openInputStream(imageUri)?.let {
                    it?.use { inputStream ->

                    }
                }
            }
        }
    }

ولكن هذه المرة قمنا باستخدام Images.Media Collections بدلاً من Downloads .

الآن قبل كتابة الملف, بما انه ليس لدينا ملف وإنما imageUri يجب علينا أولا العثور على هذا Uri وثم قرائته وأخيراً الكتابة

لهذا قمنا باستخدام ContentResolver للعثور على هذا Uri ومن ثم فتحنا inputStream لنقرأ مابداخله

والآن أخيراً سنقوم بكتابة هذا الملف الى ملف جديد

    private fun saveImage(imageUri: Uri) {


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

            val picturesCollection =
                MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

            val contentValues = ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, "Wallpaper.jpg")

            }

            contentResolver.insert(picturesCollection, contentValues)?.also { uri ->
                contentResolver.openInputStream(imageUri)?.let {
                    it?.use { inputStream ->
                        contentResolver.openOutputStream(uri)?.use { outputStream ->
                            inputStream.copyTo(outputStream)
                        }
                    }
                }

            }
        }
    }

نقوم بتشغيل التطبيق ونرى أنه تم حفظ الصورة في Pictures Folder

 

ولحفظ الصورة داخل مجلد ما داخل مجلد Pictures يمكننا استخدام RelativePath لتصبح بهذا الشكل

 private fun saveImage(imageUri: Uri) {


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

            val picturesCollection =
                MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

            val contentValues = ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, "Wallpaper.jpg")
                put(
                    MediaStore.Audio.Media.RELATIVE_PATH,
                    "${Environment.DIRECTORY_PICTURES}/Devlomi/"
                )
            }

            contentResolver.insert(picturesCollection, contentValues)?.also { uri ->
                contentResolver.openInputStream(imageUri)?.let {
                    it?.use { inputStream ->
                        contentResolver.openOutputStream(uri)?.use { outputStream ->
                            inputStream.copyTo(outputStream)
                        }
                    }
                }

            }
        }
    }

 

يمكننا استخدام نفس الطريقة لحفظ فيديو ما أيضاً باستخدام MediaStore API

نقوم بتشغيل Video Picker Intent وننتظر النتيجة كما فعلنا سابقاً في Image Picker

      val contract = ActivityResultContracts.StartActivityForResult()
        val launcher = registerForActivityResult(contract) { result ->
            if (result.resultCode == RESULT_OK) {
                result.data?.data?.let { videoUri ->
                    saveVideo(videoUri)
                }
            }
        }

        val photoPickerIntent = Intent(Intent.ACTION_PICK)
        photoPickerIntent.type = "video/*"
        launcher.launch(photoPickerIntent)

 

ثم نقوم بتطبيق نفس الخطوات ولكن هذه المرة نضع Video Collection بدلاً من Images Collection 

    private fun saveVideo(videoUri: Uri) {


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {

            val videosCollection =
                MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)

            val contentValues = ContentValues().apply {
                put(MediaStore.Downloads.DISPLAY_NAME, "Video.mp4")
            }

            contentResolver.insert(videosCollection, contentValues)?.also { uri ->
                contentResolver.openInputStream(videoUri)?.let {
                    it?.use { inputStream ->
                        contentResolver.openOutputStream(uri)?.use { outputStream ->
                            inputStream.copyTo(outputStream)
                        }
                    }
                }

            }
        }
    }

 

يمكننا تطبيق نفس الفكرة بالنسبة للملفات الصوتية, ولكن لن نشرحها في هذا المقال.

 

قراءة الملفات من الذاكرة الخارجية Scoped Storage :

سنقوم باستخدام مثال بسيط وهو عرض الصور الموجودة على الهاتف داخل RecyclerView, وعند الضغط على صورة ما سيتم عرض Dialog للتأكيد على حذف الصورة.

بعكس كتابة الملفات, يجب إضافة صلاحيات READ_EXTERNAL_STORAGE على كل API Levels. لهذا سنقوم بإضافته الى AndroidManifest

    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>


سنقوم بإنشاء Data Class الذي سيحتوي على معلومات كل صورة مثل ID,Uri, يمكنك أيضاً إضافة اسم الصورة,العرض,الطول الخ.. ولكننا سنكتفي فقط ب ID,URI.

وسنقوم بانشاء ItemCallback من أجل recyclerView

data class GalleryImage(val id: Long, val contentUri: Uri) {

    companion object {
        val diffCallback = object : DiffUtil.ItemCallback<GalleryImage>() {
            override fun areItemsTheSame(oldItem: GalleryImage, newItem: GalleryImage): Boolean {
                return oldItem.id == newItem.id
            }

            override fun areContentsTheSame(oldItem: GalleryImage, newItem: GalleryImage): Boolean {
                return oldItem == newItem
            }

        }
    }
}

ثم نقوم بإنشاء ListAdapter

class ImagesAdapter(private val clickListener: (image: GalleryImage) -> Unit) :
    ListAdapter<GalleryImage, ImagesAdapter.ImageHolder>(GalleryImage.diffCallback) {


    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ImageHolder {
        val itemView =
            LayoutInflater.from(parent.context).inflate(R.layout.item_image, parent, false)
        return ImageHolder(itemView)
    }

    override fun onBindViewHolder(holder: ImageHolder, position: Int) {
        holder.bind(getItem(position))
    }

    inner class ImageHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        init {
            itemView.setOnClickListener {
                clickListener.invoke(getItem(adapterPosition))
            }
        }
        private val imageView = itemView.findViewById<ImageView>(R.id.image_view)

        fun bind(image: GalleryImage) {
            imageView.setImageURI(image.contentUri)
        }

    }
}

 

وننشئ ملف item_image.xml الذي بدوره سيحتوي على ImageView

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">

    <ImageView
        android:id="@+id/image_view"
        android:layout_width="100dp"
        android:layout_height="100dp" />
</FrameLayout>

الآن نقوم بربط RecyclerView مع Adapter

    recyclerView = findViewById(R.id.recycler_view)

        adapter = ImagesAdapter {
            //OnClick
        }
        
        recyclerView.layoutManager = GridLayoutManager(this, 3)
        recyclerView.adapter = adapter

ملاحظة: لم أهتم بالشكل GridLayout, لذلك أنصحك بتعديله ليصبح شكله أفضل.

 

سنقوم أولاً بطلب صلاحيات القراءة

   private fun checkPermissionAndLoadPhotos(){
        askPermission(Manifest.permission.READ_EXTERNAL_STORAGE){
         
        }
    }

والآن نقوم بإنشاء الميثود المسؤولة عن جلب الصور,وبما أن هذه العملية قد تستغرق وقتاً طويلاً, فمن الأفضل استدعاؤها في IO Thread بدلاً من Main Thread, ولهذا سنقوم باستخدام Coroutines.

 private suspend fun loadImages(): List<GalleryImage> {
        return withContext(IO) {
            val collection =
                MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)


            val projection = arrayOf(
                MediaStore.Images.Media._ID
            )
            val photos = mutableListOf<GalleryImage>()
            contentResolver.query(
                collection,
                projection,
                null,
                null,
                "${MediaStore.Images.Media.DISPLAY_NAME} DESC"
            )?.use { cursor ->
                val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
                while (cursor.moveToNext()) {
                    val id = cursor.getLong(idColumn)

                    val contentUri = ContentUris.withAppendedId(
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                        id
                    )
                    photos.add(GalleryImage(id, contentUri))
                }
                photos.toList()
            } ?: listOf()
        }
    }

 بدايةً قمنا بتعريف Images Collection

ثم قمنا بتعريف projection وهو المسؤول عن جلب معلومات الصورة كID وName وWidth وHeight والخ.. ولكن سنكتفي فقط ب ID.

ثم نقوم بإنشاء list جديدة لنضع الصور التي تم جلبها.

ثم نقوم بتنفيذ query, كما يمكننا وضع الترتيب الذي نريده, في حالتنا ترتيب اسم الصورة بوضع تنازلي DESC 

عند تنفيذ query فإنه يعطينا cursor والذي بدوره يحتوي على كل الصور التي تم جلبها.

نقوم بعمل while loop لبدء اخذ كل الصور داخل cursor ونقوم بأخذ ID و ContentUri ونقوم بإضافتهم ك GalleryImage ونقوم بإضافتهم داخل List.

 

نقوم باستدعاء هذه الميثود بعد التحقق من الصلاحيات

  private fun checkPermissionAndLoadPhotos(){
        askPermission(Manifest.permission.READ_EXTERNAL_STORAGE){
            lifecycleScope.launch {
                val images = loadImages()
                withContext(Main) {
                    adapter.submitList(images)
                }

            }
        }
    }

بعد الحصول على list التي تحتوي على الصور نقوم بتحديث Adapter عبر submitList

 

حذف الصور من Scoped Storage :

سنقوم بتطبيق مثال بسيط, وهو عند الضغط على صورة ما سيظهر Dialog وعند التأكيد من المستخدم سيتم حذف الصورة

   adapter = ImagesAdapter {
            //OnClick
            AlertDialog.Builder(this).apply {
                title = "Delete"
                setMessage("Are you sure you want to delete this image?")
                setNegativeButton("Cancel", null)
                setPositiveButton("Delete") { _, _ ->

               //TODO DELETE IMAGE

                }
                show()
            }
        }

الآن سنقوم بحذف الصورة عند التأكيد من المستخدم

   adapter = ImagesAdapter {
            //OnClick
            AlertDialog.Builder(this).apply {
                title = "Delete"
                setMessage("Are you sure you want to delete this image?")
                setNegativeButton("Cancel", null)
                setPositiveButton("Delete") { _, _ ->
                    contentResolver.delete(it.contentUri,null,null)
                }
                show()
            }
        }

اذا قمنا بتجريب تشغيل التطبيق على API  30 مثلاً, سيقوم بعمل Crash.

 

وذلك لأنه API 29 فما فوق يطلب صلاحيات اضافية وموافقة اضافية من المستخدم لحذف هذه الصورة حيث أنها صور من الهاتف.

لهذا سنقوم بتعريف Intent الذي سيقوم بالطلب من المستخدم من الموافقة على حذف هذه الصورة

    private lateinit var deleteIntentLauncher: ActivityResultLauncher<IntentSenderRequest>
   deleteIntentLauncher =
            registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
                if (it.resultCode == RESULT_OK) {
                    if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {

                        it.data?.data?.let { uri ->
                            contentResolver.delete(uri, null, null)
                            Toast.makeText(this, "Image Deleted.", Toast.LENGTH_SHORT).show()
                        }


                    }
                }
            }

واذا تمت الموافقة من المستخدم يعني أن RESULT == OK , عندها سنقوم بحذف الصورة عبر contentResolver.delete

أخيراً نقوم بتشغيل هذا Intent عند الضغط على الصورة.

   adapter = ImagesAdapter {
            //OnClick
            AlertDialog.Builder(this).apply {
                title = "Delete"
                setMessage("Are you sure you want to delete this image?")
                setNegativeButton("Cancel", null)
                setPositiveButton("Delete") { _, _ ->

                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                        val intentSender =
                            MediaStore.createDeleteRequest(
                                contentResolver,
                                listOf(it.contentUri)
                            ).intentSender

                        deleteIntentLauncher.launch(
                            IntentSenderRequest.Builder(intentSender).build()
                        )
                    }

                }
                show()
            }
        }

نقوم بتشغيل التطبيق لنرى النتيجة

وعند الضغط على صورة ما سيظهر هذا Dialog من System للتأكيد على الحذف

 

بعد التأكيد سيتم حذف الصورة من الهاتف, ولكن لن يتم تحديث Adapter , لتحديثه يمكننا استدعاء loadImages() مرة أخرى. او يمكننا حذفه من List واستدعاء adapter.submitList() مرة أخرى.

الآن سنقوم ببعض التحسينات على عملية الحذف لجعلها متوافقة مع جميع اصدارات الأندرويد

سنقوم بإنشاء ميثود بسيط لحذف الصورة, سيكون بالشكل التالي:

    private fun deleteImage(imageUri: Uri) {
            try {
                contentResolver.delete(imageUri, null, null)
            } catch (e: SecurityException) {
                val intentSender = when {
                    Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
                        MediaStore.createDeleteRequest(contentResolver, listOf(imageUri)).intentSender
                    }
                    Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
                        val recoverableSecurityException = e as? RecoverableSecurityException
                        recoverableSecurityException?.userAction?.actionIntent?.intentSender
                    }
                    else -> null
                }
                intentSender?.let { sender ->
                    deleteIntentLauncher.launch(
                        IntentSenderRequest.Builder(sender).build()
                    )
                }
            }

    }

قمنا بوضع الكود داخل try catch, في حال كان الإصدار أقل من API 29 سيتم استدعاء contentResolver.delete والتي من المفروض أنها لاتقوم بعمل throw exception.

في حال تم عمل throw exception سنقوم بإنشاء Intent على حسب اصدار الهاتف 

وأخيراً نقوم بتشغيل Intent

 

كتابة ملف ما الى الذاكرة الخارجية دون استخدام Scoped Storage او MediaStore :

في بعض الحالات قد تحتاج الى كتابة ملف ما في الذاكرة الخارجية دون الحاجة الى كتابته الى مجلد Download مثلاً, على سبيل المثال ملف Backup خاص بتطبيقك, ولهذا يمكننا استخدام Intent خاص يقوم بفتح متصفح الملفات الرئيسي في الهاتف, ثم سيقوم المستخدم بتحديد مسار الملف واسمه اذا أراد وسيعيد لنا Uri لنستطيع الكتابة عليه.

من الجدير بالذكر أن هذه العملية لا تحتاج الى صلاحيات READ or WRITE external storage حيث أنه يتم اعطاء الصلاحيات الكتابة فقط لهذا الملف وبعلم المستخدم.

بدايةً سنقوم بتعريف Intent المسؤول عن اختيار مسار الملف واسمه

    private lateinit var createFileLauncher: ActivityResultLauncher<String>

 createFileLauncher =
            registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
               
            }
    

ثم نقوم بتشغيل الintent ونقوم بإرسال اسم الملف الذي نريده, بالطبع المستخد يمكنه تغييره إذا أحب

        createFileLauncher.launch("my-pwd.txt")

في حالتنا قمنا بتعريف my-pwd.txt 

 

الآن اذا كانت النتيجة OK ستعيد لنا uri سنقوم بالكتابة باستخدام Output Stream

   createFileLauncher =
            registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri ->
                val password = "pwd"
                contentResolver.openOutputStream(uri)?.use { outputStream ->
                    outputStream.write(password.toByteArray())
                }
            }

        createFileLauncher.launch("my-pwd.txt")

 

 قراءة ملف ما من الذاكرة الخارجية دون استخدام Scoped Storage او MediaStore :

سنقوم بنفس الفكرة السابقة تماماً, ولكن سنقوم بعمل Open Document Intent بدلاً من Create Document.

من الجدير بالذكر أيضاً أن هذه العملية لا تحتاج الى صلاحيات READ or WRITE external storage حيث أنه يتم اعطاء الصلاحيات القراءة فقط لهذا الملف وبعلم المستخدم.

سنقوم بتعريف Intent Launcher

    private lateinit var openFileLauncher: ActivityResultLauncher<Array<String>>


 openFileLauncher =
            registerForActivityResult(ActivityResultContracts.OpenDocument()) {uri ->
             

            }

 

ثم نقوم بتشغيله, ونقوم بإعطائه الMIME type , في حالتنا نريد قراءة ملف الباسوورد الذي قمنا بإنشائه سابقاً, ولهذا وضعنا "text/plain"  , يمكنك تمرير عدة Mime types داخل Array اذا كنت تريد صيغ متعددة ك "image/jpeg" او"video/mp4" وغيره.  كما يمكنك وضع جميع الملفات عبر استخدام "*/*"

        openFileLauncher.launch(arrayOf("text/plain"))

أخيراً نقوم بقراءة الملف pwd وتحويله الى String وإظهارة عبر رسالة Toast.

  openFileLauncher =
            registerForActivityResult(ActivityResultContracts.OpenDocument()) {uri ->
                contentResolver.openInputStream(uri)?.use { inputStream ->
                    val bytes = inputStream.readBytes()
                    val pwd = String(bytes)
                    Toast.makeText(this, "Password is: $pwd", Toast.LENGTH_SHORT).show()
                }

            }
        openFileLauncher.launch(arrayOf("text/plain"))

 

يجدر التنويه أنه اذا كنت تطور تطبيق متصفح ملفات مثلاً فيمكنك طلب Permission MANAGE_EXTERNAL_STORAGE الذي يتجاهل Scoped Storage ولكن عند رفع التطبيق الى متجر Play سيقوم Google Play Review Tea بمراجعة التطبيق, واذا لم يكن التطبيق متصفح ملفات فإن تطبيقك سيتم رفضه من المتجر عند اضافة هذا الPermission.

مصادر قد تهمك:

 1,2,3

كلمات دليلية: scoped-storage
0
إعجاب
1220
مشاهدات
0
مشاركة
1
متابع

التعليقات (0)

لايوجد لديك حساب في عالم البرمجة؟

تحب تنضم لعالم البرمجة؟ وتنشئ عالمك الخاص، تنشر المقالات، الدورات، تشارك المبرمجين وتساعد الآخرين، اشترك الآن بخطوات يسيرة !