AndroidQ

Android 10 中的隐私权变更

隐私权变更 受影响的运用 缓解战略
分区存储 针对外部存储的过滤视图,可供给对特定于运用的文件和媒体调集的拜访权限 拜访和同享外部存储中的文件的运用 运用特定于运用的目录和媒体调集目录 了解概况
增强了用户对方位权限的控制力 仅限前台权限,可让用户更好地控制运用对设备方位信息的拜访权限 在后台时恳求拜访用户方位信息的运用 确保在没有后台方位信息更新的状况下优雅降级 运用 Android 10 中引进的权限在后台获取方位信息 了解概况
体系履行后台 Activity 针对从后台发动 Activity 施行了约束 不需求用户互动就发动 Activity 的运用 运用告诉触发的 Activity 了解概况
不行重置的硬件标识符 针对拜访设备序列号和 IMEI 施行了约束 拜访设备序列号或 IMEI 的运用 运用用户能够重置的标识符 了解概况
无线扫描权限 拜访某些 WLAN、WLAN 感知和蓝牙扫描办法需求取得准确方位权限 运用 WLAN API 和蓝牙 API 的运用 针对相关运用场景恳求 ACCESS_FINE_LOCATION 权限 了解概况

上面是官网的AndroidQ的隐私权变更链接,本文章只针对部分重大隐私权限变更做出解说说明。

从后台发动 Activity 的约束

创立高优先级告诉

Android10中, 当App无前台显现的Activity时,其发动Activity会被体系阻拦, 导致发动无效。 对此官方给予的折中计划是运用全屏Intent(full-screen intent), 既创立告诉栏告诉时, 参加full-screen intent 设置, 示例代码如下(依据官方文档修正):

      Intent fullScreenIntent = new Intent(this, CallActivity.class);
    PendingIntent fullScreenPendingIntent = PendingIntent.getActivity(this, 0,
            fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT);
    NotificationCompat.Builder notificationBuilder =
            new NotificationCompat.Builder(this, CHANNEL_ID)
        .setSmallIcon(R.drawable.notification_icon)
        .setContentTitle("Incoming call")
        .setContentText("(919) 555-1234")
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setCategory(NotificationCompat.CATEGORY_CALL)
        // Use a full-screen intent only for the highest-priority alerts where you
        // have an associated activity that you would like to launch after the user
        // interacts with the notification. Also, if your app targets Android 10
        // or higher, you need to request the USE_FULL_SCREEN_INTENT permission in
        // order for the platform to invoke this notification.
        .setFullScreenIntent(fullScreenPendingIntent, true);
    Notification incomingCallNotification = notificationBuilder.build();

留意:在Target SDk为29及以上时,需求在AndroidManifest上增加USE_FULL_SCREEN_INTENT申明

//AndroidManifest中
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />

当手机处于亮屏状况时, 会显现一个告诉栏, 当手机处于锁屏或许灭屏状况时,会亮屏并直接进入到CallActivity中。

不行重置的设备标识符施行了约束

从 Android 10 开端,运用必须具有 READ_PRIVILEGED_PHONE_STATE 特许权限才能拜访设备的不行重置标识符(包括 IMEI 和序列号)。

受影响的办法包括:

  • Build
    
    • getSerial()
  • TelephonyManager
    
    • getImei()
    • getDeviceId()
    • getMeid()
    • getSimSerialNumber()
    • getSubscriberId()

ANDROID_ID 生成规矩:签名+设备信息+设备用户 ANDROID_ID 重置规矩:设备恢复出厂设置时,ANDROID_ID 将被重置

当前获取设备唯一ID的办法为运用ANDROID_ID, 若获取为空的话则运用UUID.randomUUID().toString()取得一个随机ID并存储起来, 该ID确保唯一, 但App卸载重装之后就会改动。

String id = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID);

约束了对剪贴板数据的拜访权限

除非您的运用是默许输入法 (IME) 或是现在处于焦点的运用,不然它无法拜访 Android 10 或更高版别平台上的剪贴板数据。

由于都是运用处于前台的时分进行剪贴板数据的获取,关于大部分事务不受影响。

定位权限

Android Q引进了新的方位权限ACCESS_BACKGROUND_LOCATION,该权限仅会影响运用在后台运行时对方位信息的拜访权。假如运用targetSDK<=P,恳求了ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION权限,AndroidQ设备会自动帮你请求ACCESS_BACKGROUND_LOCATION权限。 假如运用以 Android 10或更高版别为方针平台,则您必须在运用的清单文件中声明ACCESS_BACKGROUND_LOCATION权限并接纳用户权限,才能在运用位于后台时接纳定时方位信息更新。 以下代码段展现了如何在运用中恳求在后台拜访方位信息:

    <manifest ... >
        <!--答应取得准确的GPS定位-->
        <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
        <!--答应取得粗略的基站网络定位-->
        <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
        <!-- 兼容10.0体系,答应App在后台取得方位信息 -->
        <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
    </manifest>

以下代码段中显现了定位权限检查逻辑的示例:

    boolean permissionAccessCoarseLocationApproved =
        ActivityCompat.checkSelfPermission(this, permission.ACCESS_COARSE_LOCATION)
            == PackageManager.PERMISSION_GRANTED;
    if (permissionAccessCoarseLocationApproved) {
       boolean backgroundLocationPermissionApproved =
               ActivityCompat.checkSelfPermission(this,
                   permission.ACCESS_BACKGROUND_LOCATION)
                   == PackageManager.PERMISSION_GRANTED;
       if (backgroundLocationPermissionApproved) {
           // App can access location both in the foreground and in the background.
           // Start your service that doesn't have a foreground service type
           // defined.
       } else {
           // App can only access location in the foreground. Display a dialog
           // warning the user that your app must have all-the-time access to
           // location in order to function properly. Then, request background
           // location.
           ActivityCompat.requestPermissions(this, new String[] {
               Manifest.permission.ACCESS_BACKGROUND_LOCATION},
               your-permission-request-code);
       }
    } else {
       // App doesn't have access to the device's location at all. Make full request
       // for permission.
       ActivityCompat.requestPermissions(this, new String[] {
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_BACKGROUND_LOCATION
            },
            your-permission-request-code);
    }

假如您的运用一般需求在被置于后台后(如当用户按设备上的主屏幕按钮或封闭设备的显现屏时)拜访设备的方位信息。

要在这种特定类型的用例中保存对设备方位信息的拜访权,请发动您已在运用的清单中声明前台服务类型为 “location” 的前台服务:

    <service
        android:name="MyNavigationService"
        android:foregroundServiceType="location" ... >
        ...
    </service>

在发动该前台服务之前,请确保您的运用仍可拜访设备的方位信息:

    boolean permissionAccessCoarseLocationApproved =
        ActivityCompat.checkSelfPermission(this,
            permission.ACCESS_COARSE_LOCATION) ==
            PackageManager.PERMISSION_GRANTED;
    if (permissionAccessCoarseLocationApproved) {
        // App has permission to access location in the foreground. Start your
        // foreground service that has a foreground service type of "location".
    } else {
       // Make a request for foreground-only location access.
       ActivityCompat.requestPermissions(this, new String[] {
            Manifest.permission.ACCESS_COARSE_LOCATION},
           your-permission-request-code);
    }

分区存储

为了让用户更好地控制自己的文件,并约束文件混乱的状况,Android Q修正了APP拜访外部存储中文件的办法。外部存储的新特性被称为Scoped Storage

Android Q依然运用READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE作为面向用户的存储相关运行时权限,但现在即便获取了这些权限,拜访外部存储也受到了约束。

APP需求这些运行时权限的情景发生了变化,且各种状况下外部存储对APP的可见性也发生了变化。

Scoped Storage新特性中,外部存储空间被分为两部分:

● 公共目录:DownloadsDocumentsPicturesDCIMMoviesMusicRingtones

公共目录下的文件在APP卸载后,不会删去。

APP能够经过SAF(System Access Framework)MediaStore接口拜访其间的文件。

App-specific目录:存储运用私有数据,外部存储运用私有目录对应 Android/data/packagename,内部存储运用私有目录对应 data/data/packagename;

APP卸载后,数据会清除。

APP的私密目录,APP拜访自己的App-specific目录时无需任何权限。

AndroidQ兼容性适配指南

存储空间视图形式

Android Q规定了APP有两种外部存储空间视图形式:Legacy ViewFiltered View

Filtered View:App能够直接拜访App-specific目录,但不能直接拜访App-specific外的文件。拜访公共目录或其他APP的App-specific目录,只能经过MediaStoreSAF、或许其他APP 供给的ContentProviderFileProvider等拜访。

Legacy View: 兼容形式。与Android Q曾经一样,请求权限后App可拜访外部存储,具有完整的拜访权限

requestLegacyExternalStorage和preserveLegacyExternalStorage

requestLegacyExternalStorage 是Anroid10引进的,假如你进行适配Android 10之后,运用经过晋级装置,那么还会运用曾经的储存形式Legacy View,只要经过首次装置或是卸载从头装置才能启用新形式Filtered View

android:requestLegacyExternalStorage="true"让适配了Android10的app新装置在Android 10体系上也持续拜访旧的存储模型。

Environment.isExternalStorageLegacy();//存储是否为兼容形式

在适配Android11的时分requestLegacyExternalStorage 标签会在Android11以上的设备上被疏忽,preserveLegacyExternalStorage只是让掩盖装置的app能持续运用旧的存储模型,假如之前是旧的存储模型的话。

  • Android10适配的时分能够经过requestLegacyExternalStoragec运用兼容形式;
  • Android11适配能够经过preserveLegacyExternalStorage让Android10及一下的设备运用兼容形式,但Android11及以上的设备无论是掩盖装置仍是从头装置都无法运用兼容形式;

能够经过调用 Environment.getExternalStorageState() 查询该卷的状况。假如回来的状况为 MEDIA_MOUNTED,那么您就能够在外部存储空间中读取和写入运用专属文件。假如回来的状况为 MEDIA_MOUNTED_READ_ONLY,您只能读取这些文件。

分区存储的影响

图片方位信息

一些图片会包括方位信息,由于方位关于用户归于敏感信息, Android 10 运用在分区存储形式下图片方位信息默许获取不到,运用经过以下两项设置能够获取图片方位信息:

  • manifest 中请求 ACCESS_MEDIA_LOCATION;
  • 调用 MediaStore.setRequireOriginal(Uri uri)接口更新图片 Uri;
// Get location data from the ExifInterface class.
val photoUri = MediaStore.setRequireOriginal(photoUri)
contentResolver.openInputStream(photoUri).use { stream ->
    ExifInterface(stream).run {
        // If lat/long is null, fall back to the coordinates (0, 0).
        val latLong = ?: doubleArrayOf(0.0, 0.0)
    }
}

拜访数据

私有目录:

运用私有目录文件拜访办法与之前 Android 版别一致,能够经过 File path 获取资源。

同享目录:

同享目录文件需求经过 MediaStore API 或许 Storage Access Framework 办法拜访。 MediaStore API 在同享目录指定目录下创立文件或许拜访运用自己创立文件,不需求请求存储权限 MediaStore API 拜访其他运用在同享目录创立的媒体文件(图片、音频、视频), 需求请求存储权限,未请求存储权限,经过 ContentResolver 查询不到文件 Uri,即便经过其他办法获取到文件 Uri,读取或创立文件会抛出反常; MediaStore API 不能够拜访其他运用创立的非媒体文件(pdf、office、doc、txt 等), 只能够经过 Storage Access Framework 办法拜访;

File途径拜访受影响接口

FileOutputStreamFileInputStream

在分区存储模型下,SD卡的公共目录是不让拜访的,除了同享媒体的那几个文件夹。所以,用一个公共目录的途径实例化FileOutputStream或许FileInputStream报FileNotFoundException反常。

W/System.err: java.io.FileNotFoundException: /storage/emulated/0/Log01-28-18-10.txt: open failed: EACCES (Permission denied)
W/System.err:     at libcore.io.IoBridge.open(IoBridge.java:496)
W/System.err:     at java.io.FileInputStream.<init>(FileInputStream.java:159)

File.createNewFile

W/System.err: java.io.IOException: Permission denied
W/System.err:     at java.io.UnixFileSystem.createFileExclusively0(Native Method)
W/System.err:     at java.io.UnixFileSystem.createFileExclusively(UnixFileSystem.java:317)
W/System.err:     at java.io.File.createNewFile(File.java:1008)
  • File.renameTo
  • File.delete
  • File.renameTo
  • File.mkdir
  • File.mkdirs

以上File的办法都回来false

BitmapFactory.decodeFile生成的Bitmapnull

适配辅导

Android Q Scoped Storage新特性谷歌官方适配文档:developer.android.google.cn/preview/pri…

适配辅导如下,分为:拜访APP本身App-specific目录文件、运用MediaStore拜访公共目录、运用SAF 拜访指定文件和目录、共享App-specific目录下文件和其他细节适配。

拜访App-specific目录文件

无需任何权限,能够直接经过File的办法操作App-specific目录下的文件。

App-specific目录 接口(一切存储设备) 接口(Primary External Storage)
Media getExternalMediaDirs() NA
Obb getObbDirs() getObbDir()
Cache getExternalCacheDirs() getExternalCacheDir()
Data getExternalFilesDirs(String type) getExternalFilesDir(String type)
/**
 * 在App-Specific目录下创立文件
 * 文件目录:/Android/data/包名/files/Documents/
 */
private fun createAppSpecificFile() {
    binding.createAppSpecificFileBtn.setOnClickListener {
        val documents = getExternalFilesDirs(Environment.DIRECTORY_DOCUMENTS)
        if (documents.isNotEmpty()) {
            val dir = documents[0]
            var os: FileOutputStream? = null
            try {
                val newFile = File(dir.absolutePath, "MyDocument")
                os = FileOutputStream(newFile)
                os.write("create a file".toByteArray(Charsets.UTF_8))
                os.flush()
                Log.d(TAG, "创立成功")
                dir.listFiles()?.forEach { file: File? ->
                    if (file != null) {
                        Log.d(TAG, "Documents 目录下的文件名:" + file.name)
                    }
                }
            } catch (e: IOException) {
                e.printStackTrace()
                Log.d(TAG, "创立失利")
            } finally {
                closeIO(os)
            }
        }
    }
}
/**
 * 在App-Specific目录下创立文件夹
 * 文件目录:/Android/data/包名/files/
 */
private fun createAppSpecificFolder() {
    binding.createAppSpecificFolderBtn.setOnClickListener {
        getExternalFilesDir("apk")?.let {
            if (it.exists()) {
                Log.d(TAG, "创立成功")
            } else {
                Log.d(TAG, "创立失利")
            }
        }
    }
}

运用MediaStore拜访公共目录

MediaStore Uri和途径对应表

AndroidQ兼容性适配指南

MediaStore供给下列Uri,能够用MediaProvider查询对应的Uri数据。在AndroidQ上,一切的外部存储设备都会被命令,即Volume Name。MediaStore能够经过Volume Name 获取对应的Uri。

MediaStore.getExternalVolumeNames(this).forEach { volumeName ->
  Log.d(TAG, "uri:${MediaStore.Images.Media.getContentUri(volumeName)}")
}

Uri途径格局: content:// media/<volumeName>/<Uri途径>

AndroidQ兼容性适配指南

运用MediaStore创立文件

经过ContentResolver的insert办法,将多媒体文件保存在公共调集目录,不同的Uri对应不同的公共目录,详见3.2.1;其间RELATIVE_PATH的一级目录必须是Uri对应的一级目录,二级目录或许二级以上的目录,能够随意的创立和指定。

private lateinit var createBitmapForActivityResult: ActivityResultLauncher<String>
//注册ActivityResultLauncher
createBitmapForActivityResult =
    registerForActivityResult(ActivityResultContracts.RequestPermission()) {
        createBitmap()
    }
binding.createFileByMediaStoreBtn.setOnClickListener {
    createBitmapForActivityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
private fun createBitmap() {
    val values = ContentValues()
    val displayName = "NewImage.png"
    values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
    values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image")
    values.put(MediaStore.Images.Media.MIME_TYPE, "image/png")
    values.put(MediaStore.Images.Media.TITLE, "Image.png")
    //适配AndroidQ及一下
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
    } else {
        values.put(
            MediaStore.MediaColumns.DATA,
            "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName"
        )
    }
    //requires android.permission.WRITE_EXTERNAL_STORAGE, or grantUriPermission()
    val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    //java.lang.UnsupportedOperationException: Writing to internal storage is not supported.
    //val external = MediaStore.Images.Media.INTERNAL_CONTENT_URI
    val insertUri = contentResolver.insert(external, values)
    var os: OutputStream? = null
    try {
        if (insertUri != null) {
            os = contentResolver.openOutputStream(insertUri)
        }
        if (os != null) {
            val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
            //创立了一个赤色的图片
            val canvas = Canvas(bitmap)
            canvas.drawColor(Color.RED)
            bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
            Log.d(TAG, "创立Bitmap成功")
            if (insertUri != null) {
                values.clear()
                //适配AndroidQ及一下
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl2")
                } else {
                    values.put(
                        MediaStore.MediaColumns.DATA,
                        "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName"
                    )
                }
                contentResolver.update(insertUri, values, null, null)
            }
        }
    } catch (e: IOException) {
        Log.d(TAG, "创立失利:${e.message}")
    } finally {
        closeIO(os)
    }
}
运用MediaStore查询文件

经过 Cursor query(@RequiresPermission.Read @NonNull Uri uri,@Nullable String[] projection, @Nullable String selection,@Nullable String[] selectionArgs, @Nullable String sortOrder) 办法。

参数解说:

参数 类型 释义
uri Uri 供给检索内容的 Uri,其 scheme 是content://
projection String[] 回来的列,假如传递 null 则一切列都回来(效率低下)
selection String 过滤条件,即 SQL 中的 WHERE 语句(但不需求写 where 本身),假如传 null 则回来一切的数据
selectionArgs String[] 假如你在 selection 的参数加了 ? 则会被本字段中的数据按顺序替换掉
sortOrder String 用来对数据进行排序,即 SQL 语句中的 ORDER BY(单不需求写ORDER BY 本身),假如传 null 则按照默许顺序排序(可能是无序的)

经过ContentResolver.query接口查询文件Uri,查询其他App创立的文件是需求READ_EXTERNAL_STORAGE权限;

该查询运用的是手机体系的数据库查询,可能会呈现有些图片文件存在可是依旧查询不到~!(PS:运用adb命令push的图片就查询不到)

/**
 * 经过MediaStore查询文件
 */
private fun queryFileByMediaStore() {
  queryPictureForActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
      queryUri = queryImageUri("yellow.jpg")
  }
  binding.queryFileByMediaStoreBtn.setOnClickListener {
      queryPictureForActivityResult.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
  }
}
/**
  * @param displayName 查询的图片文件名称
  * @return 第一个遍历到的该文件名的uri
  */
private fun queryImageUri(displayName: String): Uri? {
    val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    val selection = "${MediaStore.Images.Media.DISPLAY_NAME}=?"
    val args = arrayOf(displayName)
    val projection = arrayOf(MediaStore.Images.Media._ID)
    val cursor = contentResolver.query(external, projection, selection, args, null)
    var queryUri: Uri? = null
    if (cursor != null) {
        //可能查询到多个同名图片
        while (cursor.moveToNext()) {
            queryUri = ContentUris.withAppendedId(external, cursor.getLong(0))
            Log.d(TAG, "查询成功,Uri途径$queryUri")
            queryUri?.let {
                cursor.close()
                return it
            }
        }
        cursor.close()
    }
    return queryUri;
}
运用MediaStore读取文件

首要需求文件存储权限,经过ContentResolver.query查询得到的Uri之后,能够经过contentResolver.openFileDescriptor,依据文件描述符挑选对应的翻开办法。”r”表明读,”w”表明写;

private lateinit var readPictureForActivityResult: ActivityResultLauncher<IntentSenderRequest>
/**
 * 依据查询到的uri,获取bitmap
 */
private fun readFileByMediaStore() {
    readPictureForActivityResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
        readBitmapNotException()
    }
    binding.readFileByMediaStoreBtn.setOnClickListener {
        readBitmapNotException()
    }
}
private fun readBitmapNotException() {
    val queryUri = queryImageUri("20221018_113937.jpg")
    if (queryUri != null) {
        var pfd: ParcelFileDescriptor? = null
        try {
            pfd = contentResolver.openFileDescriptor(queryUri, "r")
            if (pfd != null) {
                // 第一次解析将inJustDecodeBounds设置为true,来获取图片巨细
                val options = BitmapFactory.Options()
                options.inJustDecodeBounds = true
                BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, options)
                // 调用上面定义的办法计算inSampleSize值
                options.inSampleSize = calculateInSampleSize(options, 500, 500)
                // 运用获取到的inSampleSize值再次解析图片
                options.inJustDecodeBounds = false
                val bitmap = BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, options)
                binding.imageIv.setImageBitmap(bitmap)
            }
        } catch (e: Exception) {
            e.printStackTrace()
        } finally {
            closeIO(pfd)
        }
    } else {
        Log.d(TAG, "还未查询到Uri")
    }
}

获取图片的缩略图:

拜访Thumbnail,经过ContentResolver.loadThumbnail传入size,回来指定巨细的缩略图。

/**
  * 依据查询到的Uri,获取Thumbnail
  */
private fun loadThumbnail() {
    binding.loadThumbnailBtn.setOnClickListener {
        queryUri?.let {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val bitmap = contentResolver.loadThumbnail(it, Size(100, 200), null)
                binding.imageIv.setImageBitmap(bitmap)
            } else {
                MediaStore.Images.Thumbnails.getThumbnail(
                    contentResolver,
                    ContentUris.parseId(it),
                    MediaStore.Images.Thumbnails.MINI_KIND,
                    null)?.let { bitmap ->
                    binding.imageIv.setImageBitmap(bitmap)
                }
            }
        }
    }
}
运用MediaStore修正文件

PS:仅限AndroidQ及以上体系版别,低版别国产手机运用ContentResolver进行数据更新存在数据和文件不同步问题以及缩略图和原图不同步问题;

当运用具有了 WRITE_EXTERNAL_STORAGE 权限后,当修正其他 App 的文件时,会 throw 另一个 Exception:

android.app.RecoverableSecurityException: com.tzx.androidsystemversionadapter has no access to content://media/external/images/media/21

假如咱们将这个 RecoverableSecurityException Catch住,并向用户请求修正该图片的权限,用户操作后,咱们就能够在 onActivityResult回调中拿到成果进行操作了。

AndroidQ兼容性适配指南

/**
  * 依据查询得到的Uri,修正文件
  */
private fun updateFileByMediaStore() {
  updatePictureForActivityResult =
      registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
          updateFileNameWithException()
      }
  registerForActivityResult =
      registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
          updateFileNameWithException()
      }
  binding.updateFileByMediaStoreBtn.setOnClickListener {
      registerForActivityResult.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE))
  }
}
private fun updateFileNameWithException() {
  val queryUri = queryImageUri("blue.jpg")
  var os: OutputStream? = null
  try {
      queryUri?.let { uri ->
          os = contentResolver.openOutputStream(uri)
          os?.let {
              val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
              //创立了一个赤色的图片
              val canvas = Canvas(bitmap)
              canvas.drawColor(Color.YELLOW)
              //从头写入文件内容
              bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
              val contentValues = ContentValues()
              //给改文件重命名
              contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, "yellow.jpg")
              contentResolver.update(uri, contentValues, null, null)
          }
      }
  } catch (e: Exception) {
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
          if (e is RecoverableSecurityException) {
              try {
                  updatePictureForActivityResult.launch(
                      IntentSenderRequest.Builder(e.userAction.actionIntent.intentSender)
                          .build()
                  )
              } catch (e2: IntentSender.SendIntentException) {
                  e2.printStackTrace()
              }
              return
          }
      }
      e.printStackTrace()
  }
}
运用MediaStore删去文件

删去自己创立的多媒体文件不需求权限,其他APP创立的,与修正类型,需求用户授权。

/**
  * 删去MediaStore文件
  */
private fun deleteFileByMediaStore() {
    deletePictureRequestPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
        if (it) {
            deleteFile()
        } else {
            Log.d(TAG, "deleteFileByMediaStore: 授权失利")
        }
    }
    deletePictureSenderRequestActivityResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
        if (it.resultCode == RESULT_OK) {
            deleteFile()
        } else {
            Log.d(TAG, "updateFileByMediaStore: 授权失利")
        }
    }
    binding.deleteFileByMediaStoreBtn.setOnClickListener {
        deletePictureRequestPermissionActivityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    }
}
private fun deleteFile() {
    val queryUri = queryImageUri("2021-10-14_11.19.18.882.png")
    try {
        if (queryUri != null) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val delete = contentResolver.delete(queryUri, null, null)
                //delete=0删去失利,delete=1也不一定删去成功,必须要颁发文件的写权限
                Log.d(TAG, "contentResolver.delete:$delete")
            } else {
                val filePathByUri = UriTool.getFilePathByUri(this@ScopedStorageActivity, queryUri)
                File(filePathByUri).delete()
            }
        }
    } catch (e: Exception) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            if (e is RecoverableSecurityException) {
                try {
                    deletePictureSenderRequestActivityResult.launch(
                        IntentSenderRequest.Builder(e.userAction.actionIntent.intentSender)
                            .build()
                    )
                } catch (e2: IntentSender.SendIntentException) {
                    e2.printStackTrace()
                }
                return
            }
        }
        e.printStackTrace()
    }
}

运用Storage Access Framework

Android 4.4(API 级别 19)引进了存储拜访框架Storage Access Framework (SAF)。凭借 SAF,用户可轻松在其一切首选文档存储供给程序中浏览并翻开文档、图画及其他文件。用户可经过易用的规范界面,以一致办法在一切运用和供给程序中浏览文件,以及拜访最近运用的文件。

SAF google官方文档 developer.android.google.cn/guide/topic…

AndroidQ兼容性适配指南

SAF本地存储服务的围绕 DocumentsProvider 完成的,经过Intent调用DocumentUI,由用户在DocumentUI上挑选要创立、授权的文件以及目录等,授权成功后再onActivityResult回调用拿到指定的Uri,依据这个Uri可进行读写等操作,这时分已经赋予文件读写权限,不需求再动态请求权限。

运用SAF挑选单个文件

经过Intent.ACTION_OPEN_DOCUMENT调文件挑选界面,用户挑选并回来一个或多个现有文档,一切选定的文档均具有耐久的读写权限颁发,直至设备重启。

private lateinit var createFileActivityResult: ActivityResultLauncher<Intent>
/**
 * 挑选一个文件,这儿翻开一个图片作为演示
 */
private fun selectSingleFile() {
    selectSingleFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (it.resultCode == RESULT_OK) {
            //获取文档
            val uri = it?.data?.data
            if (uri != null) {
                dumpImageMetaData(uri)//dump图片的信息进行打印
                getBitmapFromUri(uri)?.let {
                    binding.showIv.setImageBitmap(it)
                }
                Log.d(TAG, "图片的line :${readTextFromUri(uri)}")
            }
        }
    }
    binding.selectSingleFile.setOnClickListener {
        safSelectSingleFileActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            // Filter to only show results that can be "opened", such as a
            // file (as opposed to a list of contacts or timezones)
            addCategory(Intent.CATEGORY_OPENABLE)
            // Filter to show only images, using the image MIME data type.
            // If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
            // To search for all documents available via installed storage providers,
            // it would be "*/*".
            type = "image/*"
        })
    }
}
运用SAF创立文件

可经过运用 Intent.ACTION_CREATE_DOCUMENT,能够供给 MIME 类型和文件名,但最终成果由用户决议

private fun createFile(mimeType: String, fileName: String) {
    createFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (it.resultCode == Activity.RESULT_OK) {
            //创立文档
            val uri = it?.data?.data
            if (uri != null) {
                Log.d(TAG, "创立文件成功")
                binding.createFileUriTv.text = TAG
                binding.createFileUriTv.visibility = View.VISIBLE
                dumpImageMetaData(uri)//dump图片的信息进行打印
            }
        }
    }
    binding.createFileBtn.setOnClickListener {
        createFileActivityResult.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            // Filter to only show results that can be "opened", such as
            // a file (as opposed to a list of contacts or timezones).
            addCategory(Intent.CATEGORY_OPENABLE)
            // Create a file with the requested MIME type.
            type = mimeType
            putExtra(Intent.EXTRA_TITLE, fileName)
        })
    }
}
运用SAF删去文件

需求留意的是此刻的Uri是经过Document授权的,例如:content://com.android.providers.media.documents/document/image:14766。而不是这种content://media/external/images/media/14760

假如您取得了文档的 URI,而且文档 Document.COLUMN_FLAGS 包括 FLAG_SUPPORTS_DELETE,则便可删去该文档。

private fun checkUriFlag(uri: Uri, flag: Int): Boolean {
    try {
        val cursor = contentResolver.query(uri, null, null, null, null)
        if (cursor != null && cursor.moveToFirst()) {
            val columnIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS)
            val columnFlags = cursor.getInt(columnIndex)
            Log.i(TAG,"Column Flags:$columnFlags  Flag:$flag")
                  if ((columnFlags and  flag) == flag) {
                return true
            }
            cursor.close()
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return false
}

这儿做一个解说经过一下,咱们能够先看DocumentsContract.java的源代码。

//android.provider.DocumentsContract.java
public static final int FLAG_SUPPORTS_THUMBNAIL = 1;
public static final int FLAG_SUPPORTS_WRITE = 1 << 1;
public static final int FLAG_SUPPORTS_DELETE = 1 << 2;
public static final int FLAG_DIR_SUPPORTS_CREATE = 1 << 3;
/**
 * Flags that apply to a document. This column is required.
 * <p>
 * Type: INTEGER (int)
 *
 * @see #FLAG_DIR_BLOCKS_OPEN_DOCUMENT_TREE
 * @see #FLAG_DIR_PREFERS_GRID
 * @see #FLAG_DIR_PREFERS_LAST_MODIFIED
 * @see #FLAG_DIR_SUPPORTS_CREATE
 * @see #FLAG_PARTIAL
 * @see #FLAG_SUPPORTS_COPY
 * @see #FLAG_SUPPORTS_DELETE
 * @see #FLAG_SUPPORTS_METADATA
 * @see #FLAG_SUPPORTS_MOVE
 * @see #FLAG_SUPPORTS_REMOVE
 * @see #FLAG_SUPPORTS_RENAME
 * @see #FLAG_SUPPORTS_SETTINGS
 * @see #FLAG_SUPPORTS_THUMBNAIL
 * @see #FLAG_SUPPORTS_WRITE
 * @see #FLAG_VIRTUAL_DOCUMENT
 * @see #FLAG_WEB_LINKABLE
 */
public static final String COLUMN_FLAGS = "flags";

能够看出,flag是经过二进制的位做区别的。所以判别是否判别包括某个flag能够运用位操作与Document.COLUMN_FLAGS做比较。

 /**
  * 假如您取得了文档的 URI,而且文档的 Document.COLUMN_FLAGS 包括 FLAG_SUPPORTS_DELETE,则便可删去该文档
  */
private fun deleteFile() {
    binding.deleteFileBtn.setOnClickListener {
        queryUri = Uri.parse("content://com.android.providers.media.documents/document/image%3A14766")
        queryUri?.let { url ->
            if (checkUriFlag(url, DocumentsContract.Document.FLAG_SUPPORTS_DELETE)) {
                val deleted = DocumentsContract.deleteDocument(contentResolver, url)
                val s = "删去$url$deleted"
                Log.d(TAG, "deleteFile:$s")
                if (deleted) {
                    binding.createFileUriTv.text = ""
                }
            }
        }
    }
}
运用SAF更新文件

这儿的Uri,是经过用户挑选授权的Uri,经过Uri获取ParcelFileDescriptor或许翻开OutputStream进行修正。

private fun editDocument() {
    editFileActivityResult =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            if (it.resultCode == RESULT_OK) {
                //修正文档
                val uri = it?.data?.data
                if (uri != null) {
                    alterDocument(uri)//更新文档
                }
            }
        }
    binding.editDocumentBtn.setOnClickListener {
        editFileActivityResult.launch(
            // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's
            // file browser.
            Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
                // Filter to only show results that can be "opened", such as a
                // file (as opposed to a list of contacts or timezones).
                addCategory(Intent.CATEGORY_OPENABLE)
                // Filter to show only text files.
                type = "text/plain"
            })
    }
}
运用SAF获取目录&保存授权

运用ACTION_OPEN_DOCUMENT_TREEintent,拉起DocumentUI让用户自动授权的办法 获取,取得用户自动授权之后,运用就能够临时取得该目录下面的一切文件和目录的读写权限,能够经过DocumentFile操作目录和其下的文件。

在这个过程中经过用户授权的Uri,就默许获取了该Uri的读写权限,直到设备重启。能够经过保存权限来永久的获取该权限,不需求每次重启手机之后又要从头让用户自动授权。

contentResolver.takePersistableUriPermission办法能够校验当前的Uri是否有相关授权能够进行文件读写;

/**
 * 运用saf挑选目录
 */
private fun getDocumentTree() {
    selectDirActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        //挑选目录
        val treeUri = it?.data?.data
        if (treeUri != null) {
            savePersistablePermission(treeUri)//将获取的权限耐久化保存
            val root = DocumentFile.fromTreeUri(this, treeUri)
            root?.listFiles()?.forEach { it ->
                Log.d(TAG, "目录下文件名称:${it.name}")
            }
        }
    }
    binding.getDocumentTreeBtn.setOnClickListener {
        val sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE)//获取缓存的权限
        val uriString = sp.getString("uri", "")
        if (!uriString.isNullOrEmpty()) {
            try {
                val treeUri = Uri.parse(uriString)
                // Check for the freshest data.
                contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
                Log.d(TAG, "已经取得永久拜访权限")
                val root = DocumentFile.fromTreeUri(this, treeUri)
                root?.listFiles()?.forEach { it ->
                    Log.d(TAG, "目录下文件名称:${it.name}")
                }
            } catch (e: SecurityException) {
                Log.d(TAG, "uri 权限失效,调用目录获取")
                selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
            }
        } else {
            Log.d(TAG, "没有永久拜访权限,调用目录获取")
            selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
        }
    }
}
运用SAF进行重命名
private fun renameFileName() {
    binding.renameFileBtn.setOnClickListener {
        queryUri?.let {
            val uri = it
            //小米8 Android9 抛出java.lang.UnsupportedOperationException: Rename not supported反常
            //Pixel 6a Android13能够正常重命名
            if (checkUriFlag(uri, DocumentsContract.Document.FLAG_SUPPORTS_RENAME)) {
                try {
                    //假如文件名已存在,会报错java.lang.IllegalStateException: File already exists:
                    DocumentsContract.renameDocument(contentResolver, uri, "slzs.txt")
                    Log.d(TAG, "renameFileName" + "重命名成功")
                } catch (e: FileNotFoundException) {
                    Log.d(TAG, "renameFileName" + "重命名失利,文件不存在")
                }
            } else {
                Log.d(TAG, "renameFileName" + "重命名失利,权限校验失利")
            }
        }
    }
}
运用自定义DocumentsProvider

假如你期望自己运用的数据也能在documentsui中翻开,能够自定义一个document provider。APP能够完成自定义ContentProvider来向外供给APP私有文件。 一般的文件管理类的软件都会运用自定义的DocumentsProvider。这种办法十分适用于内部文件共享,不期望有UI交互的状况。 ContentProvider相关的Google官方文档: developer.android.google.cn/guide/topic…

下面介绍自定义DocumentsProvider的步骤:

  • API版别为19或许更高
  • 在manifest.xml中注册该Provider
  • Provider的name为类名加包名,比方: com.example.android.storageprovider.MyCloudProvider
  • Authority为包名+provider的类型名,如: com.example.android.storageprovider.documents
  • android:exported属性的值为ture
<manifest... >
    ...
    <uses-sdk
        android:minSdkVersion="19"
        android:targetSdkVersion="19" />
        ....
        <provider
            android:name="com.example.android.storageprovider.MyCloudProvider"
            android:authorities="com.example.android.storageprovider.documents"
            android:grantUriPermissions="true"
            android:exported="true"
            android:permission="android.permission.MANAGE_DOCUMENTS"
            android:enabled="@bool/atLeastKitKat">
            <intent-filter>
                <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
            </intent-filter>
        </provider>
    </application>
</manifest>

兼容性影响

Scoped Storage关于APP拜访外部存储办法、APP数据寄存以及APP间数据同享,都发生很大影响。请开发者留意以下的兼容性影响事项。

无法新建文件

问题原因:

直接运用本身App-specific目录以外的途径新建文件。

问题剖析:

在Android Q上,APP只答应在本身App-specific目录以内经过途径生成的文件。

解决计划:

APP本身App-specific目录下新建文件的办法与文件途径,请拜见拜访App-specific目录文件;假如要在公共目录下新建文件,运用MediaStore 接口,请拜见运用MediaStore拜访公共目录;假如要在恣意目录下新建文件,需求运用SAF,请拜见[运用Storage Access Framework](#运用Storage Access Framework)。

无法拜访存储设备上的文件

问题原因1:

直接运用途径拜访公共目录文件。

问题剖析1:

在Android Q上,APP默许只能拜访外部存储设备上的App-specific目录。

解决办法1:

拜见运用MediaStore拜访公共目录和运用SAF挑选单个文件,运用MediaStore接口拜访公共目录中的多媒体文件,或许运用 SAF拜访公共目录中的恣意文件。

留意:从MediaStore接口中查询到的DATA字段将在Android Q开端抛弃,不应该运用它来拜访文件或许判别文件是否存在;从 MediaStore接口或许SAF获取到文件Uri后,请运用Uri翻开FD 或许输入输出流,而不要转换成文件途径去拜访。

问题原因2:

运用MediaStore接口拜访非多媒体文件。

问题剖析2:

在Android Q上,运用MediaStore接口只能拜访公共目录中的多媒体文件。

解决办法2:

运用SAF向用户请求文件或目录的读写权限,请拜见运用SAF挑选单个文件。

无法正确共享文件

问题原因:

APP将App-specific目录中的私有文件共享给其他APP时,运用了file://类型的 Uri。

问题剖析:

在Android Q上,由于App-specific目录中的文件是私有受维护的,其他APP无法经过文件途径拜访。

解决计划:

拜见共享处理,运用FileProvider,将content://类型的Uri共享给其他 APP。

无法修正存储设备上的文件

问题原因1:

直接运用途径拜访公共目录文件。

问题剖析1:

同无法拜访存储设备上的文件。

解决计划1:

同无法拜访存储设备上的文件,请运用正确的公共目录文件拜访办法。

问题原因2:

运用MediaStore接口获取公共目录多媒体文件的Uri后,直接运用该Uri翻开 OutputStream或文件描述符。

问题剖析2:

Android Q上,修正公共目录文件,需求用户授权。

解决计划2:

MediaStore接口获取公共目录多媒体文件Uri后,翻开OutputStream FD时,留意catch RecoverableSecurityException,然后向用户请求该多媒体文件的修改权限,请拜见运用MediaStore修正文件;运用SAF 获取到文件或目录的Uri时,用户已经授权读写,能够直接运用,但要留意Uri权限的时效,请拜见运用SAF获取目录&保存授权。

运用卸载后文件意外删去

问题原因:

将想要保存的文件保存在外部存储的App-specific目录下。

问题剖析:

Android Q上,卸载APP默许删去App-specific目录下的数据。

解决计划:

APP应该将想要保存的文件经过MediaStore接口保存到公共目录下,请拜见运用MediaStore拜访公共目录。默许状况下,MediaStore 接口会将非媒体类文件保存到Downloads目录下,推荐APP指定一级目录为Documents。假如APP 想要在卸载时保存App-specific目录下的数据,要在AndroidManifest.xml中声明android:hasFragileUserData="true",这样在 APP卸载时就会有弹出框提示用户是否保存运用数据。

无法拜访图片文件中的地理方位数据

问题原因:

直接从图片文件输入流中解析地理方位数据。

问题剖析:

由于图片的地理方位信息触及用户隐私,Android Q上默许不向APP供给该数据。

解决计划:

请求ACCESS_MEDIA_LOCATION权限,并运用MediaStore.setRequireOriginal()接口更新文件Uri,请拜见图片方位信息。

ota晋级问题(数据迁移)

问题原因:

ota晋级后,APP被卸载,从头装置后无法拜访到APP数据。

问题剖析:

Scoped Storage新特性只对Android Q上新装置的APP收效。设备从Android Q 之前的版别晋级到Android Q,已装置的APP取得Legacy View视图。

这些APP 假如直接经过途径的办法将文件保存到了外部存储上,例如外部存储的根目录,那么APP被卸载后从头装置,新的APP取得Filtered View视图,无法直接经过途径拜访到旧数据,导致数据丢掉。

解决计划:

  • APP应该修正保存文件的办法,不再运用途径的办法直接保存,而是选用MediaStore接口将文件保存到对应的公共目录下。

  • 在ota晋级前,能够将APP 的用户历史数据经过MediaStore接口迁移到公共目录下。此外,APP应当改动拜访App-specific目录以外的文件的办法,请运用MediaStore 接口或许SAF

  • 针对只要运用自己拜访而且运用卸载后答应删去的文件,需求迁移文件到运用私有目录文件,能够经过 File path 办法拜访文件资源,降低适配本钱。

  • 答应其他运用拜访,而且运用卸载后不答应删去的文件,文件需求存储在同享目录,运用能够挑选是否进行目录整改,将文件迁移到 Androidq 要求的 media 调集目录。

共享处理

APP能够挑选以下的办法,将本身App-specific目录下的文件共享给其他APP读写。

运用FileProvider

FileProvider相关的Google官方文档: developer.android.google.cn/reference/a… developer.android.com/training/se…

FileProvider归于在Android7.0的行为变更,各种帖子很多,这儿就不具体介绍了。 为了避免和已有的三方库抵触,建议选用extends FileProvider的办法。

public class TakePhotoProvider extends FileProvider {...}
<application>
        <provider
            android:name=".TakePhotoProvider"
            android:authorities="${applicationId}.fileProvider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/take_file_path" />
        </provider>
</application>
运用ContentProvider

APP能够完成自定义ContentProvider来向外供给APP私有文件。这种办法十分适用于内部文件共享,不期望有UI交互的状况。 ContentProvider相关的Google官方文档: developer.android.google.cn/guide/topic…

运用DocumentsProvider

详见运用自定义DocumentsProvider

相关API运用问题

MediaStore DATA字段不再可靠

Android QDATA(即_data)字段开端抛弃,不再表明文件的实在途径。

读写文件或判别文件是否存在,不应该运用DATA字段,而要运用openFileDescriptor。 一起也无法直接运用途径拜访公共目录的文件。

MediaStore 文件增加Pending状况

AndroidQ上,MediaStore中增加MediaStore.Images.Media.IS_PENDING ,flag用来表明文件的Pending状况,0是可见,其他不行见,假如没有设置setIncludePending接口,查询不到设置IS_PENDIN 的文件,能够用来下载,或许出产截图等等。

ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, "myImage.PNG");
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
ContentResolver resolver = context.getContentResolver();
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Uri item = resolver.insert(uri, values);
try {
    ParcelFileDescriptor pfd = resolver.openFileDescriptor(item, "w", null);
    // write data into the pending image.
} catch (IOException e) {
    LogUtil.log("write image fail");
}
// clear IS_PENDING flag after writing finished.
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0);
resolver.update(item, values, null, null);
MediaStore 相对途径

AndroidQ中,经过MediaSore将多媒体没见储存在公共目录下,除了默许的一级目录,还能够指定次级目录,对应的一级目录详见下表:

AndroidQ兼容性适配指南

val values = ContentValues()
//Pictures为一级目录对应Environment.DIRECTORY_PICTURES,sl为二级目录
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val insertUri = contentResolver.insert(external, values)
values.clear()
//DCIM为一级目录对应Environment.DIRECTORY_DCIM,sl为二级目录,sl2为三级目录
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/sl/sl2")
contentResolver.update(insertUri,values,null,null)

参考资料

OPPO 敞开平台 Android Q版别运用兼容性适配辅导

GitHub Demo