Simple Player 1 - Query 解說

2023/09/16 Projects 36083 words,~ 104 min

專案簡介

不知道你是否跟我一樣,平時會從不同地方 (TikTok, Line … ) 下載喜歡的影片然後希望之後可以慢慢回味。 但當我們使用相簿 App 開啟時才發現,我們無法無限地播放影片,更無法將影片歸入播放清單。 Â 為了解決這問題,我決定建立一個簡單的 Media Player App ( 取名 SMP Simple Media mediaPlayer )來提供使用者一個可以好好享受音樂的平台。

功能介紹

  1. 播放有效的影音檔 ( midi, wav, mp3, mp4, ogg, 3gp, amr, aac, flac, mkv, webm )
  2. 記錄使用者最近的播放內容
  3. 使用者可以建立 PlayList
  4. 讓使用者可以通過 BlueTooth 分享音樂
  5. ( optional ) 建立 Server 讓使用者可以存放影音檔,並可以隨時下載與上傳。
  6. ( optional ) 讓使用者之間進行串流。
  7. ( optional ) 提供免費的網站音樂讓使用者串流與下載。

UI

UI 將才取 TikTok Vibe:

而影片與音樂

解說

存取影片功能

Manifest

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

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

Permission Request 在這裡我將 Permission 相關的行為寫入 singleton PermissionUtils。 如此一來我們就可以通過 PermissionUtils 進行 Permission 的請求、顯示 Permission 的 Rationale、以及導向 Settings 的功能。

另外還搭配 SharedPreferences 來記錄請求次數。 因為 Google 要求請求次數在 2 次之後就無法再次進行 Permission 的請求。此時我們就必須讓使用者導向 Settings 來手動更改。

PermissionUtils

object PermissionUtils {

    /**
     * Request Code for Storage
     */
    const val STORAGE_REQUEST_CODE = 100

    /**
     * check if storage permission is granted
     * @return true if permission is granted. You can call #requestStoragePermission afterward
     */
    fun isStoragePermissionGranted(context: Context): Boolean {
        return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
            checkPermissions(context, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)
        }
        else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
            checkPermissions(context, Manifest.permission.READ_EXTERNAL_STORAGE)
        } else {
            checkPermissions(context, Manifest.permission.READ_MEDIA_VIDEO)
        }
    }

    private fun checkPermissions(context: Context, vararg permissions: String): Boolean {
        for (permission in permissions) {
            if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_DENIED) {
                return false
            }
        }
        return true
    }

    fun handleStoragePermissionResult(permissions: Array<out String>,
                                      grantResults: IntArray,
                                      context: AppCompatActivity) {
        val nonGrantedPermissions = arrayListOf<String>()

        for (i in 0 until grantResults.size) {
            if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
                nonGrantedPermissions.add(permissions[i])
            }
        }

        if (nonGrantedPermissions.size > 0) {

            val shouldRedirectToSettings = SharedPref.getRequestCountForStoragePermission(
                STORAGE_PERMISSION_REQUEST_COUNT) >= 1

            showRationaleForStoragePermission(context, shouldRedirectToSettings, onPositiveClicked = {

                if (shouldRedirectToSettings) {
                    // direct to setting
                    (context.application as SMPApplication).goToSettings()
                } else {
                    SharedPref.incrementStoragePermissionCounter(STORAGE_PERMISSION_REQUEST_COUNT)
                    requestStoragePermission(context)
                }

                it.dismiss()
            }, onNegativeClicked = {
                it.dismiss()
            })

        } else {
        //  val t = MediaStore.Images.Media.INTERNAL_CONTENT_URI
            val t = MediaStore.Files.getContentUri("external")
            Log.i("TAG", t.path?:"NONE")
        }
    }

    /**
     *
     */
    fun requestStoragePermission(activity: Activity): Boolean {

        val permissions = arrayListOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE)


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q /*29*/) {
            permissions.clear()

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                permissions.add(Manifest.permission.READ_MEDIA_VIDEO)
            } else {
                permissions.add(Manifest.permission.READ_EXTERNAL_STORAGE)
            }
        }

        for (permission in permissions) {
            if (ContextCompat.checkSelfPermission(activity, permission) == PackageManager.PERMISSION_DENIED) {
                ActivityCompat.requestPermissions(activity, permissions.toTypedArray(), STORAGE_REQUEST_CODE)
                return false;
            }
        }

        return true;
    }

    /**
     *
     */
    private fun showRationaleForStoragePermission(context: AppCompatActivity,
                                          shouldRedirectToSettings: Boolean = false,
                                          onPositiveClicked:(DialogInterface)->Unit, onNegativeClicked: (DialogInterface)->Unit ) {
        // create a dialog
        val dia = AlertDialog.Builder(context).apply {
            setCancelable(false)
            setTitle(context.resources.getString(R.string.permission_required_title))
            setMessage(context.resources.getString( if (shouldRedirectToSettings) R.string.msg_storage_permission_denied_warning else R.string.msg_storage_permission_required))

            setPositiveButton(
                context.resources.getString(if (shouldRedirectToSettings) R.string.btn_settings
                else R.string.btn_ok)) { dialog: DialogInterface, which: Int ->
                onPositiveClicked(dialog)
            }

            setNegativeButton(context.resources.getString(R.string.btn_cancel)) {dialog: DialogInterface, which: Int ->
                onNegativeClicked(dialog)
            }
        }

        dia.show()
    }

}


Media Query

想要進行多媒體的讀取,我們可以從兩個地方著手:

  • MediaStore (官方推薦)
  • File (主要用來開啟 App 專屬檔案,但也可以開啟 非專屬檔案)


MediaStore is the contract between the media provider and applications.

所以 App 可以通過 MediaStoreMediaProvider 溝通。 其中的 MediaProvider 其實就是 ContentProvider

在這因為我其實不了解這兩者的差別,所以我都會去試試看。

基礎流程

在說 MediaStoreFile 的使用方法之前,我們需要先了解 Query 的流程。

  1. 取得資料夾的位置 ( Uri )。
  2. 定義想要取得的資訊 (String[] projection)
  3. 定義針對資訊的要求 (String selection)
  4. 定義排序 (String sortOrder)
  5. 通過 ContentResolver 以及以上的資訊跟系統的 ContentProvider 拿資料 (query)。
  6. 得到資料後就使用 Cursor 取得對應的資料並寫入客製化的類別。

那我們接下來就要看如何使用 MediaStore 以及 File 得到我們想要的資料。

MediaStore

取得 Uri

假設我們需要取得 App 外部或非專屬或共享的多媒體,我們就可以通過以下方式取得:

// 官方源碼
val collection =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Video.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL
        )
    } else {
        MediaStore.Video.Media.EXTERNAL_CONTENT_URI
    }

雖然 MediaStore.class 中顯示 MediaStore.Video.Media.EXTERNAL_CONTENT_URI 為 null,但運作時會回傳:

content://media/external/video/media

也就是以一個 Uri 的形式顯示:

{scheme} : // {host or authority} / {path}

這也是我們需要用的,因為 SMP 並不會創造新的 Media,所以沒必要讀取專屬資料。

那如果想要取得圖檔或是圖跟影片檔呢?

我們可以使用:

MediaStore.Files.getContentUri("external")

我們會取得:

content://media/external/file

定義 Projection

接下來我們需要定義我們想要在這些檔案中取得的資訊,像是名稱、 ID、影片長度、音效是否為鬧鐘、等等。

我們可以從 MediaStore 取得資料類型所支援的 Columns 或 資料名稱。

  • Audio - MediaStore.Audio.AudioColumns
  • Video - MediaStore.Video.VideoColumns
  • Image - MediaStore.Images.ImageColumns
  • File - MediaStore.File.FileColumns

若是從 MediaStore.File 取得,那我們可以通過 selection 來指定要取出哪些類型的資料,包括: Document, Audio, Video, Image, Subtitle, PlayList (31 deprecated) 還有 None,也就是除之前所提到的類型之外的類型。

因為我們想要取得影片與音樂,所以我們就使用 MediaStore.File。

另外,由於 MediaStore.Media.MediaColumns 是上述全部類型 Columns 的父類別。 所以我們的 FileColumns 可以寫成:


fun getFileColumns(): Array<String> {
    val columns = getMediaColumns().toMutableSet()

    columns.add(MediaStore.Files.FileColumns.MEDIA_TYPE)
    columns.add(MediaStore.Files.FileColumns.MIME_TYPE)
    columns.add(MediaStore.Files.FileColumns.PARENT) // The index of the parent directory of the file

    return columns.toTypedArray()
}

當然,如果我們不需要全部的資訊時,我們可以隨意挑選。

以下是其他 Columns 的方法:

enum class TargetTypes {
    VIDEO,
    AUDIO,
    IMAGE,
    ALL
}

val imageInternalUri: Uri = MediaStore.Images.Media.INTERNAL_CONTENT_URI
val imageExternalUri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val videoInternalUri: Uri = MediaStore.Video.Media.INTERNAL_CONTENT_URI
val videoExternalUri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI

@SuppressLint("ObsoleteSdkInt")
fun getMediaColumns(): Array<String> {
    val columns = mutableSetOf(
        BaseColumns._ID,
        MediaStore.MediaColumns.DISPLAY_NAME,
        MediaStore.MediaColumns.RELATIVE_PATH, MediaStore.MediaColumns.DATA,
        MediaStore.MediaColumns.DATE_ADDED,
        MediaStore.MediaColumns.DATE_MODIFIED,
        MediaStore.MediaColumns.MIME_TYPE,
        MediaStore.MediaColumns.SIZE,
        MediaStore.MediaColumns.TITLE,
    )

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
        columns.add(MediaStore.MediaColumns.HEIGHT)
        columns.add(MediaStore.MediaColumns.WIDTH)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        columns.apply {
            add(MediaStore.MediaColumns.BUCKET_DISPLAY_NAME)
            add(MediaStore.MediaColumns.BUCKET_ID)
            add(MediaStore.MediaColumns.DATE_EXPIRES)
            add(MediaStore.MediaColumns.DATE_TAKEN)
            add(MediaStore.MediaColumns.DOCUMENT_ID)
            add(MediaStore.MediaColumns.DURATION)
            add(MediaStore.MediaColumns.INSTANCE_ID)
            add(MediaStore.MediaColumns.IS_PENDING)
            add(MediaStore.MediaColumns.ORIENTATION)
            add(MediaStore.MediaColumns.ORIGINAL_DOCUMENT_ID)
            add(MediaStore.MediaColumns.OWNER_PACKAGE_NAME)
            add(MediaStore.MediaColumns.RELATIVE_PATH)
        }
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        columns.apply {
            add(MediaStore.MediaColumns.ALBUM)
            add(MediaStore.MediaColumns.ALBUM_ARTIST)
            add(MediaStore.MediaColumns.ARTIST)
            add(MediaStore.MediaColumns.AUTHOR)
            add(MediaStore.MediaColumns.BITRATE)
            add(MediaStore.MediaColumns.CAPTURE_FRAMERATE)
            add(MediaStore.MediaColumns.CD_TRACK_NUMBER)
            add(MediaStore.MediaColumns.COMPILATION)
            add(MediaStore.MediaColumns.COMPOSER)
            add(MediaStore.MediaColumns.DISC_NUMBER)
            add(MediaStore.MediaColumns.GENERATION_ADDED)
            add(MediaStore.MediaColumns.GENERATION_MODIFIED)
            add(MediaStore.MediaColumns.GENRE)
            add(MediaStore.MediaColumns.IS_DOWNLOAD)
            add(MediaStore.MediaColumns.IS_DRM)
            add(MediaStore.MediaColumns.IS_FAVORITE)
            add(MediaStore.MediaColumns.IS_TRASHED)
            add(MediaStore.MediaColumns.NUM_TRACKS)
            add(MediaStore.MediaColumns.XMP)
            add(MediaStore.MediaColumns.WRITER)
            add(MediaStore.MediaColumns.VOLUME_NAME)
            add(MediaStore.MediaColumns.RESOLUTION)
            add(MediaStore.MediaColumns.YEAR)

        }
    }

    return columns.toTypedArray()
}

fun getImageColumns(): Array<String> {
    val columns = getMediaColumns().toMutableSet()

    columns.add(MediaStore.Images.ImageColumns.DESCRIPTION)
    columns.add(MediaStore.Images.ImageColumns.IS_PRIVATE)
    columns.add(MediaStore.Images.ImageColumns.LATITUDE)
    columns.add(MediaStore.Images.ImageColumns.LONGITUDE)
    columns.add(MediaStore.Images.ImageColumns.MINI_THUMB_MAGIC)
    columns.add(MediaStore.Images.ImageColumns.PICASA_ID)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        columns.remove(MediaStore.Images.ImageColumns.LATITUDE)
        columns.remove(MediaStore.Images.ImageColumns.LONGITUDE)
        columns.remove(MediaStore.Images.ImageColumns.MINI_THUMB_MAGIC)
        columns.remove(MediaStore.Images.ImageColumns.PICASA_ID)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        columns.addAll(
            arrayListOf(
                // ImageColumns
                MediaStore.Images.ImageColumns.EXPOSURE_TIME,
                MediaStore.Images.ImageColumns.F_NUMBER,
                MediaStore.Images.ImageColumns.ISO,
                MediaStore.Images.ImageColumns.SCENE_CAPTURE_TYPE,
            )
        )
    }
    return columns.toTypedArray()
}

fun getVideoColumns(): Array<String> {
    val columns = getMediaColumns().toMutableSet()

    // VideoColumns
    columns.add(MediaStore.Video.VideoColumns.BOOKMARK)
    columns.add(MediaStore.Video.VideoColumns.CATEGORY)
    columns.add(MediaStore.Video.VideoColumns.DESCRIPTION)
    columns.add(MediaStore.Video.VideoColumns.IS_PRIVATE)
    columns.add(MediaStore.Video.VideoColumns.LANGUAGE)
    columns.add(MediaStore.Video.VideoColumns.LATITUDE)
    columns.add(MediaStore.Video.VideoColumns.LONGITUDE)
    columns.add(MediaStore.Video.VideoColumns.MINI_THUMB_MAGIC)
    columns.add(MediaStore.Video.VideoColumns.TAGS)

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        columns.remove(MediaStore.Video.VideoColumns.LATITUDE)
        columns.remove(MediaStore.Video.VideoColumns.LONGITUDE)
        columns.remove(MediaStore.Video.VideoColumns.MINI_THUMB_MAGIC)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        columns.addAll(
            arrayListOf(
                // VideoColumns
                MediaStore.Video.VideoColumns.COLOR_RANGE,
                MediaStore.Video.VideoColumns.COLOR_STANDARD,
                MediaStore.Video.VideoColumns.COLOR_TRANSFER,
            )
        )
    }
    return columns.toTypedArray()
}

fun getAudioColumns(): Array<String> {
    val columns = getMediaColumns().toMutableSet()

    columns.add(MediaStore.Audio.AudioColumns.ALBUM_ID)
    columns.add(MediaStore.Audio.AudioColumns.ARTIST_ID)
    columns.add(MediaStore.Audio.AudioColumns.BOOKMARK)
    columns.add(MediaStore.Audio.AudioColumns.IS_ALARM)
    columns.add(MediaStore.Audio.AudioColumns.IS_MUSIC)
    columns.add(MediaStore.Audio.AudioColumns.IS_NOTIFICATION)
    columns.add(MediaStore.Audio.AudioColumns.IS_PODCAST)
    columns.add(MediaStore.Audio.AudioColumns.IS_RINGTONE)
    columns.add(MediaStore.Audio.AudioColumns.TRACK)
    columns.add(MediaStore.Audio.AudioColumns.YEAR)
    columns.add(MediaStore.Audio.AudioColumns.TITLE_KEY)


    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        columns.add(MediaStore.Audio.AudioColumns.IS_AUDIOBOOK)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
        columns.addAll(
            arrayListOf(
                MediaStore.Audio.AudioColumns.GENRE,
                MediaStore.Audio.AudioColumns.GENRE_ID,
                MediaStore.Audio.AudioColumns.TITLE_RESOURCE_URI // The resource URI of a localized title, if any. https://developer.android.com/reference/android/provider/MediaStore.Audio.AudioColumns#TITLE_RESOURCE_URI
            )
        )
        columns.remove(MediaStore.Audio.AudioColumns.TITLE_KEY)
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        columns.add(MediaStore.Audio.AudioColumns.IS_RECORDING)
    }

    return columns.toTypedArray()
}


值得注意的是: 雖然 MediaStore.MediaColumns.DATA 在 API 29後被棄用,但目前 (33) 卻還是可以使用。


你可能會想: 竟然我們需要影片與音樂檔案,那為什麼我們不順便將 MediaStore.Audio.AudioColumnsMediaStore.Video.VideoColumns 加入 projection 呢?

其實答案很簡單。因為在查詢時,我們只能針對固定類型的 Columns。 也就是說, MediaStore.File 只能使用 FileColumns。

如果有無法找到的 projection 就會得到 java.lang.IllegalArgumentException: Invalid column

至於我們要如何取得所需要的資料,之後會提到。

定義 Selection

接下來我們需要通過 SQL 語法定義我們要哪些資料。

假設我們只想要影片超過某長度的話,就可以寫:

val sel = MediaStore.Video.Media.DURATION + " >= ?"

在通過 Selection Args 來定義 ? 是多少。這裡我們定義 5 分鐘:

val selectionArgs = arrayOf(
                TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES).toString()
            )

那如果我們要定義 File 的類別呢?

我們可以使用:

val sel = "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ${MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO} || ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ${MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO}"

val selectionArgs = null

val sel = "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?"

val selectionArgs = arrayOf(
  ${MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO},
  ${MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO}
  )

當然,我們還可以通過其他運算子來設定 Selection,可以去 w3school 來學習。

定義 Sort Order

一般來說,我們會按加入的時間來排列,從最新到最晚:

val sortOrder = "${MediaStore.Files.FileColumns.DATE_TAKEN} DESC"

// or

val sortOrder = "${MediaStore.MediaColumns.DATE_TAKEN} DESC"

我們還可以按多個 Column 來進行排列:

val sortOrder = "${MediaStore.Files.FileColumns.DATE_TAKEN} DESC , ${MediaStore.Files.FileColumns.SIZE} ASC"

進行 Query

Query (查詢) 時,你可以想像有一個表格提供給我們:

index (optional) Column_A Column_B
0 a b

這是因為在電腦中,檔案的資訊會以表格的方式存取起來。所以我們必須使用 Cursor 來讀取。

我們需要先記錄 Column 的 index :

這些 index 是由我們的 projection 順序來訂的
val query = contentResolver.query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)

query.use {cursor ->
    // Cache column indices.
    cursor?.let {
        val mediaTypeCol = it.getColumnIndex(MediaStore.Files.FileColumns.MEDIA_TYPE)
        val mimiTypeCol = it.getColumnIndex(MediaStore.Files.FileColumns.MIME_TYPE)
        val parentTypeCol = it.getColumnIndex(MediaStore.Files.FileColumns.PARENT)

        val displayNameCol = it.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
        val durationCol = it.getColumnIndex(MediaStore.MediaColumns.DURATION)
        val pathCol = it.getColumnIndex(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) MediaStore.MediaColumns.RELATIVE_PATH else MediaStore.MediaColumns.DATA)

    }
}

取得 Column Index 後就需要讀取對應的值:

while (it.moveToNext()) {
    val path = it.getString(pathCol)
    val type = it.getInt(mediaTypeCol)
    val name = it.getString(displayNameCol)
    Log.d("SMP", "PATH = $path , TYPE = $type, name = $name")
}

如果我們有錄影,那就會得到 PATH = DCIM/Camera/ , TYPE = 3, name = {name} . mp4

以上就是 API 29 以上得到的答案。

但若是 29 以前, PATH 會由 MediaStore.MediaColumns.DATA 取得: | /storage/emulated/0/DCIM/Camera/VID_20230910_090407.mp4


切記: 若 column 並不存在,我們就會得到 -1 喔 !

以上就是我們目前取得資料的方法。接下來,我們看看使用 File 時要如何取得。

File

想要取得 /storage/emulated/0/DCIM/Camera/VID_20230910_090407.mp4 我們必須要進入 /storage/emulated/0/DCIM/Camera 中。

這個路徑可以由 Environment.DIRECTORY_DCIM 取得:

val file = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM + "/Camera")

由此我們可以取得這個檔案夾的全部檔案:

file.listFiles()

但這當然不是我們所要的,我們需要使用 FileFilter 來取得特定類別:

val filter = object: FileFilter {

    val acceptedFileExtensions = ".(mp4|mkv|webm|avi)\$"
    val pattern = Pattern.compile(acceptedFileExtensions, Pattern.CASE_INSENSITIVE)

    override fun accept(pathname: File?): Boolean {
        if (pathname == null) return false
        return pathname.isFile && pattern.matcher(pathname.name).find()
    }
}

以上 filter 會讓我們取得 mp4, mkv, webm, avi 的影片檔案。 如果也想要拿圖檔,那就更改為以下即可:

".(jpg|png|gif|jpe|jpeg|bmp|webp|mp4|mkv|webm|avi)$"

那也就是說,File 有以下的好處:

  • 可以讓我們搜索 特定的資料夾
  • 可以讓我們搜索 特定的檔案類型

Environment 除了能讓我們找到照相機的資料,還能夠提供多個共享及系統資料夾的位置 ( /system, /data 與 /storage ) :

// 取得 /system
Environment.getRootDirectory()

// 取得 /data
Environment.getDataDirectory()

// 取得 /data/cache
// 這檔案中包含了 backup, backup_stage, recovery 三個檔案
Environment.getDownloadCacheDirectory()

// 取得 /storage
Environment.getStorageDirectory()

// 取得 /storage/emulated/0
Environment.getExternalStorageDirectory()

// 取得 /storage/emulated/0/Alarms
// 我們還可以通過傳入不同的 DIRECTORY_{TYPE} 來取得
// /storage/emulated/0/{TYPE} 的路徑
// 包括: DIRECTORY_MUSIC , DIRECTORY_PODCASTS, DIRECTORY_RINGTONES
//      DIRECTORY_ALARMS, DIRECTORY_NOTIFICATIONS, DIRECTORY_PICTURES
//      DIRECTORY_MOVIES, DIRECTORY_DOWNLOADS, DIRECTORY_DCIM
//      DIRECTORY_DOCUMENTS, DIRECTORY_SCREENSHOTS, DIRECTORY_AUDIOBOOKS
//      DIRECTORY_RECORDINGS
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_ALARMS)

取得檔案更多訊息

由於我們只取得針對 File 相關的資訊,所以就算我們知道檔案類型,我們依舊無法取得對方的相關資訊。

對此,我們有兩種做法:

  1. 使用 ContentProvider 相關的 Uri 進行查詢
  2. 使用 完整路徑 讀取檔案

ContentProvider

所謂 ContentProvider 相關的 Uri 其實就是從 MediaStore.Files.getContentUri 取得:

public static Uri getContentUri(String volumeName)
public static Uri getContentUri(String volumeName, long rowId)

ContentProvider 相關的 Uri 會以 content://

我們可以傳入不同的 volumeName 包括: external, external_primaryinternalAPI 30 之後也可以從 MediaStore 參數取得:

// content://media/external/file/
public static final String VOLUME_EXTERNAL = "external";

// content://media/external_primary/file
public static final String VOLUME_EXTERNAL_PRIMARY = "external_primary";

// content://media/internal/file
public static final String VOLUME_INTERNAL = "internal";

如果我們傳入了 rowId,所得到的 Uri 會是:

content://media/{location}/file/{rowId}

// ie content://media/external/file/0
// {prefix}: // {authority} / {data_type} / {id}

authority 可以從 MediaStore.AUTHORITY 來取得。

參考影片

我們也可以通過 Uri 取得 rowId。但若是 Uri 中沒有數字,Long.Parser 就會失敗並拋出錯誤訊息。

val row: Long = ContentUris.parseId(uri);

當我們想要取得某檔案的更多訊息時,我們可以通過資料中的 MediaStore.MediaColumns._ID 來建立新的 Uri,並進行查詢 :

query.use {cursor ->
    // Cache column indices.
    cursor?.let {
      val idCol = it.getColumnIndex(MediaStore.MediaColumns._ID)
      while (it.moveToNext()) {
        val uri = ContentUris.withAppendedId(MediaStore.Files.getContentUri("external"), id)

        contentResolver.query(uri, arrayOf(MediaStore.Video.Thumbnails.DATA), null, null, null).use {
              it?.let {
                  val thumbDataCol =
                      it.getColumnIndex(MediaStore.Video.VideoColumns.Thumbnails.DATA)
                  while (it.moveToNext()) {
                      val c = it.getInt(thumbDataCol)
                      Log.d("SMP", "C = $c")
                  }
              }
          }
      }
    }
}

如此一來就可以在 API 29 之前取得 Thumbnails 了。

在 API 29 及以上需要用到 contentResolver.loadThumbnail 來進行:

contentResolver.loadThumbnail(uri, Size(100, 100), null)

由於想要顯示就需要知道大小。 所以他們會在 View 相關的類型中調用。像是: RecyclerView.ViewHolder。

完整路徑

想要取得完整路徑,我們有兩種方式取得:

  1. 查詢 MediaStore.MediaColumns.DATA
  2. 通過 Environment

如之前所說, 官網宣稱 DATA 在 API 29 就廢棄了,但測試時 API 33 還是可用的。 也有人說 API 32 又可以用了。

** ** 無論如何,我們就先 假設 官方沒錯吧 :upside_down_face:

API 29 之後就只能通過查詢 RELATIVE_PATH 與 NAME 來取得相關資訊。

經由測試,我們知道 /storage/emulated/0/DCIM/Camera/VID_20230910_090407.mp4 其實可以看成:

/storage/emulated/0 {RELATIVE_PATH} {NAME}

問題就在如何得到 /storage/emulated/0/ 這部分了。

我們可以通過

Environment.getStorageDirectory() // 取得 /storage/emulated/0

Environment.getStorageDirectory() // 取得 /storage

畢竟我們能取得的資料夾位置也就三種: /system/data/storage 。 應該不難知道應該調用哪個方法。

Query 的實作

從上面你應該看得出來,想要進行 Query 其實需要寫不少代碼。但身為工程師的我們一定要遵守如何能懶則懶的原則,所以為了更容易地進行查詢,我參考了 leafpicrevived 中的 Query.java 來定義一個 Builder

在此之前,我們需要先暸解 SQL 查詢時的 語法。歡迎自行閱讀 :stuck_out_tongue:

對應著 Android 的 Query 寫法,SQL 會得到以下格式:

SELECT {projection}
FROM {uri}
WHERE {selection + selectionArgs}
ORDER BY {sortOrder}

但其實 SQL 還有很多語法可以使用,包括: DISTINCT, LIMIT, GROUP BY, HAVING, BETWEEN 等等。

我們再次參考 leafpicrevived 中的 CPHelper.java

private static String getHavingClause(int excludedCount) {

    if (excludedCount == 0)
        return "(";

    StringBuilder res = new StringBuilder();
    res.append("HAVING (");

    res.append(MediaStore.Images.Media.DATA).append(" NOT LIKE ?");

    for (int i = 1; i < excludedCount; i++)
        res.append(" AND ")
                .append(MediaStore.Images.Media.DATA)
                .append(" NOT LIKE ?");

    // NOTE: dont close ths parenthesis it will be closed by ContentResolver
//        res.append(")");

    return res.toString();

}

而這個方法的用法為:

query.selection(String.format("%s=? or %s=?) GROUP BY (%s) %s ",
    MediaStore.Files.FileColumns.MEDIA_TYPE,
    MediaStore.Files.FileColumns.MEDIA_TYPE,
    MediaStore.Files.FileColumns.PARENT,
    getHavingClause(excludedAlbums.size())));

這清楚地顯示,原來我們可以將所需要的語法串連在 selection 的字串中。

** 但很可惜,由於安全的疑慮,這方法已經無法使用了**

我會在另一篇中建造 App 來做範例。

Builder

由於這個 App 所需要的功能並不多,包括:

  • 取得資料
    • NAME, RELATIVE_PATH, DATA, AUTHORITY, AUTHORITY_URI, DURATION, DATE_ADDED, DATE_MODIFIED, ARTIST … 晚點補充
  • 過濾資料
    • NAME, RELATIVE_PATH, AUTHORITY, AUTHORITY_URI, DATE_ADDED, DATE_MODIFIED, ARTIST …
  • 排列順序
    • NAME, SIZE, AUTHORITY, AUTHORITY_URI, DURATION, DATE_ADDED, DATE_MODIFIED
  • GROUP BY
    • ARTIST_ID, RELATIVE_PATH, AUTHORITY, AUTHORITY_URI, DATE_ADDED

而我希望能像 leafpicrevived 一樣,可以有以下調用架構:

Query.Builder query = new Query.Builder()
      .uri(MediaStore.Files.getContentUri("external"))
      .projection(Media.getProjection())
      .sort(sortingMode.getMediaColumn())
      .ascending(sortingOrder.isAscending());

QueryUtils.query(
      query.build(),
      contentResolver,
      media)

所以我的 Builder 會是如下:

class Builder() {
    private var uri: Uri? = null
    private var projection: Array<String>? = null
    private var selection: String? = null
    private var selectionArgs: Array<Any>? = null
    private var sortByColumns: Array<String>? = null
    private var sortArgs: Array<SortOrder>? = null

    fun uri(uri: Uri): Builder {
        this.uri = uri
        return this
    }

    fun getUri() = uri

    fun projection(projection: Array<String>?): Builder {
        this.projection = projection
        return this
    }

    fun getProjection() = projection

    fun selection(selection: String?): Builder {
        this.selection = selection
        return this
    }

    fun getSelection() = selection

    fun selectionArgs(selectionArgs: Array<Any>?): Builder {
        this.selectionArgs = selectionArgs
        return this
    }

    fun getSelectionArgs(): Array<String>? {

        selectionArgs?.let {
            val array = Array(it.size) {""}

            for (i in 0 until array.size) {
                array[i] = it[i].toString()
            }

            return array
        }

        return null
    }

    fun sortByColumns(columns: Array<String>?): Builder {
        this.sortByColumns = columns
        return this
    }

    fun getSortByColumns() = sortByColumns

    fun sortArgs(args: Array<SortOrder>?): Builder {
        this.sortArgs = args
        return this
    }

    fun getSortArgs() = sortArgs
}

而 Query 則會通過 Builder 建立需要進行 Query 的所有參數:

class Query(private val builder: Builder) {

    enum class SortOrder {
        ASC, DESC
    }

    var uri: Uri? = null
    var projection: Array<String>? = null
    var selection: String? = null
    var selectionArgs: Array<String>? = null
    var sortByColumns: Array<String>? = null
    var sortArgs: Array<SortOrder>? = null

    init {
        uri = builder.getUri()
        projection = builder.getProjection()
        selection = builder.getSelection()
        selectionArgs = builder.getSelectionArgs()
        sortByColumns = builder.getSortByColumns()
        sortArgs = builder.getSortArgs()
    }

    fun getCursor(cr: ContentResolver): Cursor? {
        return withNoNulls(uri, projection, selection) {uri, projection, selection ->
            cr.query(uri, projection, selection, selectionArgs, getSortString())
        }
    }

    private fun getSortString(): String? {

        sortByColumns?.let {columns ->
            sortArgs?.let {args ->
                val size = Math.min(columns.size, args.size)
                val stringBuilder = StringBuilder()

                for (i in 0 until size) {
                    stringBuilder.append(columns[i])
                    stringBuilder.append(" ")
                    stringBuilder.append(args[i])

                    if (i < size - 1) {
                        stringBuilder.append(", ")
                    }
                }
                return stringBuilder.toString()
            }
            throw IllegalStateException("Missing sortArgs in Query")
        }

        return null
    }

    override fun toString() = "Query(" +
            "\nuri = $uri, " +
            "\nprojection = $projection" +
            "\nselection = $selection, " +
            "\nselectionArg = $selectionArgs" +
            "\nsortOrder = ${getSortString()})"
}

// https://discuss.kotlinlang.org/t/kotlin-null-check-for-multiple-nullable-vars/1946/48
inline fun <R, A, B, C> withNoNulls(p1: A?, p2: B?, p3: C?, function: (p1: A, p2: B, p3: C) -> R): R?  {
    p1?.let { p2?.let { p3?.let { function.invoke(p1, p2, p3) } } }
    return null
}

// 還需要設定 GROUP_BY

建立 Media

接下來就是需要將 Query 的結果記錄下來。 此時我們就可以建立一個類別來記錄,這裡我使用了 Media

Media 有以下的屬性: relativePath, absolutePath, title, id, duration, artist, artistID, album, albumArtist, bitRate, bucketName, displayName, dateModified, height, width, mimeType, orientation, resolution, size, volumnName, year。 基本上就是 MediaStore.MediaColumns 的大部分的屬性。

當然這些參數也會有對應的 getters。

那 setters 呢?

在此之前,我想說一下創建 Media 的流程。目前我們所看到的方式是由 contentResolver 進行設定,並在 query 方法中進行讀取再創建 Media。

但如果我希望指定 projection 呢? 我應該就會將 projection 寫在 Media 中當作 static finalconst val 吧?

query 的時候又如何知道需要 query 哪些參數呢?當然,我們可以從 Media.projection 取得。 但這樣會很醜,而且很不方便。

取而代之的是,我們設定一個 CursorHandler 的介面讓 Media 實作:

interface CursorHandler {
  fun handle(cursor: Cursor)
}

還有,因為 Media 也是有機會以 Parcel 的格式被傳入下一個 Activity 或 Fragment,所以我們也需要實作它。

以下就是 Media 的最終樣子了:

// a data class requires at least a variable in the constructor, so it's not suited for our situation
class Media() : Parcelable, CursorHandler<Media> {

    companion object CREATOR : Parcelable.Creator<Media> {
        override fun createFromParcel(parcel: Parcel): Media {
            return Media(parcel)
        }

        override fun newArray(size: Int): Array<Media?> {
            return arrayOfNulls(size)
        }

        fun sortByName() = arrayOf(MediaStore.MediaColumns.DISPLAY_NAME)

        fun sortByDate() = arrayOf(MediaStore.MediaColumns.DATE_MODIFIED)

        fun sortBySize() = arrayOf(MediaStore.MediaColumns.SIZE)

        fun getProjection(): Array<String> {
            val projection = mutableListOf<String>(
                BaseColumns._ID,
                MediaStore.MediaColumns.BUCKET_DISPLAY_NAME,
                MediaStore.MediaColumns.MIME_TYPE,
                MediaStore.MediaColumns.DISPLAY_NAME,
                MediaStore.MediaColumns.DATA, // this could result in -1 when reading column index with cursor
                MediaStore.MediaColumns.TITLE,
                // uriString is generated later
                MediaStore.MediaColumns.DATE_MODIFIED,
                MediaStore.MediaColumns.SIZE,
                MediaStore.MediaColumns.HEIGHT,
                MediaStore.MediaColumns.WIDTH,
                MediaStore.MediaColumns.ORIENTATION,
                MediaStore.Files.FileColumns.MEDIA_TYPE
            )

            if (Build.VERSION.SDK_INT >= 29) {
                projection.add(MediaStore.MediaColumns.RELATIVE_PATH)
            }

            return projection.toTypedArray()
        }


    }

    private var id: Long = -1

    private var artist: String? = null
    private var authority: String? = null
    private var bucketDisplayName: String? = null
    private var mimeType: String= MimeTypeUtils.UNKNOWN_MIME_TYPE
    private var name: String? = null
    private var path: String? = null
    private var relativePath: String? = null
    private var title: String? = null
    private var uriString: String? = null

    private var dateModified: Long = -1

    private var size: Long = -1
    private var height: Long = -1
    private var width: Long = -1
    private var orientation = 0

    private var mediaType = MediaStore.Files.FileColumns.MEDIA_TYPE_NONE

    private var selected = false

    private var indexes = Array<Int>(getProjection().size){ -1 }

    fun setIndexes(indexes: Array<Int>) {
        this.indexes = indexes
    }

    override fun handle(cursor: Cursor): Media {
        for (i in 0 until indexes.size) {

            val index = indexes[i]

            if (index == -1) continue

            when(i) {
                0 -> id = cursor.getLong(indexes[i])
                1 -> bucketDisplayName = cursor.getString(indexes[i])
                2 -> mimeType = cursor.getString(indexes[i])
                3 -> name = cursor.getString(indexes[i])
                4 -> path = cursor.getString(indexes[i])
                5 -> title = cursor.getString(indexes[i])
                6 -> dateModified = cursor.getLong(indexes[i])
                7 -> size = cursor.getLong(indexes[i])
                8 -> height = cursor.getLong(indexes[i])
                9 -> width = cursor.getLong(indexes[i])
                10 -> orientation = cursor.getInt(indexes[i])
                11 -> mediaType = cursor.getInt(indexes[i])
                12 -> relativePath = cursor.getString(indexes[i])
            }
        }

        authority = MediaStore.AUTHORITY

        return this
    }

    fun toggleSelected(): Boolean {
        selected = !selected
        return selected
    }

    fun isVideo() = mimeType.startsWith("video")
    fun isAudio() = mimeType.startsWith("audio")

    fun getUri(): Uri? {
        return if (uriString != null)
            Uri.parse(uriString)
        else if (path != null) {
            Uri.fromFile(path?.let {
                File(it)
            })
        } else null
    }

    fun getDisplayPath(): String? {

        if (path != null) return path
        else {
            getUri()?.let {
                return it.encodedPath
            }
            return null
        }
    }

    fun getName() = name

    /* Parcelable */
    constructor(parcel: Parcel) : this() {
        id = parcel.readLong()

        artist = parcel.readString()
        authority = parcel.readString()
        bucketDisplayName = parcel.readString()
        mimeType = parcel.readString()!!
        name = parcel.readString()
        path = parcel.readString()
        relativePath = parcel.readString()
        title = parcel.readString()
        uriString = parcel.readString()

        dateModified = parcel.readLong()
        size = parcel.readLong()
        height = parcel.readLong()
        width = parcel.readLong()
        orientation = parcel.readInt()

        mediaType = parcel.readInt()

        selected = parcel.readInt() != 0 // API 29 can call parcel.readBoolean()
    }

    override fun writeToParcel(parcel: Parcel, flags: Int) {
        parcel.writeLong(id)

        parcel.writeString(artist)
        parcel.writeString(authority)
        parcel.writeString(bucketDisplayName)
        parcel.writeString(mimeType)
        parcel.writeString(name)
        parcel.writeString(path)
        parcel.writeString(relativePath)
        parcel.writeString(title)
        parcel.writeString(uriString)

        parcel.writeLong(dateModified)
        parcel.writeLong(size)
        parcel.writeLong(height)
        parcel.writeLong(width)
        parcel.writeInt(orientation)

        parcel.writeInt(mediaType)

        parcel.writeInt(if (selected) 1 else 0) // API 29 can call parcel.writeBoolean()
    }

    override fun describeContents(): Int {
        return 0
    }

    class ColumnsHandler(): CursorHandler<Array<Int>> {
        override fun handle(cursor: Cursor): Array<Int> {
            return getProjection().map {
                cursor.getColumnIndex(it)
            }.toTypedArray()
        }
    }
}

Repository

有了 Model 後,我們需要建立 Repository 來負責資料的撈取。 所以建立了 IRepository

interface IRepository {
    suspend fun fetchAudios(
        uri: Uri,
        projection: Array<String>,
        sortByColumns: Array<String> = arrayOf(),
        sortOrders: Array<Query.SortOrder> = arrayOf(),
        contentResolver: ContentResolver
    ): FetchMedias

    suspend fun fetchVideos(
        uri: Uri,
        projection: Array<String>,
        sortByColumns: Array<String> = arrayOf(),
        sortOrders: Array<Query.SortOrder> = arrayOf(),
        contentResolver: ContentResolver
    ): FetchMedias

    suspend fun fetchPlayLists(): FetchPlaylists

    suspend fun fetchPlayList(): FetchPlaylist

    sealed class FetchMedias {
        data class Success(val data: Array<Media>): FetchMedias()
        data class Failed(val msg: String): FetchMedias()
    }

    sealed class FetchPlaylists {
        data class Success(val data: Array<Playlist>): FetchPlaylists()
        data class Failed(val msg: String): FetchPlaylists()
    }

    sealed class FetchPlaylist {
        data class Success(val data: Array<Media>): FetchPlaylist()
        data class Failed(val msg: String): FetchPlaylist()
    }
}

以及 Repository 來實作:

class Repository: IRepository {

    override suspend fun fetchAudios(
        uri: Uri,
        projection: Array<String>,
        sortByColumns: Array<String>,
        sortOrders: Array<Query.SortOrder>,
        contentResolver: ContentResolver
    ): IRepository.FetchMedias {

        val selection: String = "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?"
        val selectionArgs: Array<Any> = arrayOf("${MediaStore.Files.FileColumns.MEDIA_TYPE_AUDIO}")

        return withContext(Dispatchers.IO) {
            val queryBuilder = Query.Builder()
                .uri(uri)
                .projection(projection)
                .sortByColumns(sortByColumns)
                .sortArgs(sortOrders)
                .selection(selection)
                .selectionArgs(selectionArgs)
                .build()

            Log.d("SMP_FETCH_AUDIO", "query = $queryBuilder")

            val cur = queryBuilder.getCursor(contentResolver)

            cur?.use {
                val mediaColumns = Media.ColumnsHandler().handle(it)
                val medias = mutableListOf<Media>()

                while (it.moveToNext()) {
                    val media = Media()
                    media.setIndexes(mediaColumns)
                    media.handle(it)
                    medias.add(media)
                }

                IRepository.FetchMedias.Success(medias.toTypedArray())
            }
                ?: IRepository.FetchMedias.Failed("Cursor is null during fetchAudios")

        }
    }

    override suspend fun fetchVideos(
        uri: Uri,
        projection: Array<String>,
        sortByColumns: Array<String>,
        sortOrders: Array<Query.SortOrder>,
        contentResolver: ContentResolver
    ): IRepository.FetchMedias {

        val selection: String = "${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?"
        val selectionArgs: Array<Any> = arrayOf("${MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO}")

        return withContext(Dispatchers.IO) {
            val queryBuilder = Query.Builder()
                .uri(uri)
                .projection(projection)
                .sortByColumns(sortByColumns)
                .sortArgs(sortOrders)
                .selection(selection)
                .selectionArgs(selectionArgs)
                .build()

            Log.d("SMP_FETCH_VIDEO", "query = $queryBuilder")

            val cur = queryBuilder.getCursor(contentResolver)

            cur?.use {
                val mediaColumns = Media.ColumnsHandler().handle(it)
                val medias = mutableListOf<Media>()

                while (it.moveToNext()) {
                    val media = Media()
                    media.setIndexes(mediaColumns)
                    media.handle(it)
                    medias.add(media)
                }

                IRepository.FetchMedias.Success(medias.toTypedArray())
            }
                ?: IRepository.FetchMedias.Failed("Cursor is null during fetchAudios")

        }
    }

    override suspend fun fetchPlayLists(): IRepository.FetchPlaylists {
        TODO("Not yet implemented")
    }

    override suspend fun fetchPlayList(): IRepository.FetchPlaylist {
        TODO("Not yet implemented")
    }

}

目前只有實作兩個方法。

然後為了讓 Repository 可以成為 Singleton,我在裡面還加了:

companion object {
    private var mInstance: IRepository? = null

    @Synchronized
    fun getInstance(): IRepository? {

        if (mInstance == null) {
            mInstance = Repository()
        }

        return mInstance
    }
}

如此一來,我們就可以在 SMPApplication 中取得:

repository = Repository.getInstance()!!

由於 Repository 只會在 SMPApplication 中取得,所以我很確定不會有任何 race condition。因此,我使用 !! 把它給解了。

ViewModel

現在 Repository 都準備好了,我們就開始建立 ViewModel 讓 MainActivity 可以使用。

MainActivityViewModel 包含著以下資料:

  1. isLoading
  2. errorMsg
  3. audios
  4. videos
  5. playlists

這裡我只有搭配 LiveDataMutableLiveData 的運用。

另外,為了使用 viewModelScope,一個類似 Singleton 的 CoroutineScope:

public val ViewModel.viewModelScope: CoroutineScope
    get() {
        val scope: CoroutineScope? = this.getTag(JOB_KEY)
        if (scope != null) {
            return scope
        }
        return setTagIfAbsent(
            JOB_KEY,
            CloseableCoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
        )
    }

我導入了

def lifecycle_version = "2.6.2"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"

如此一來,我們的 MainActivityViewModel 就會變成以下:

class MainActivityViewModel(private val repository: IRepository): ViewModel() {

    val sortColumnsLiveData: LiveData<Array<String>>
        get() = _sortColumnsMutableLiveData

    private val _sortColumnsMutableLiveData = MutableLiveData<Array<String>>()

    val sortOrdersLiveData: LiveData<Array<Query.SortOrder>>
        get() = _sortOrdersLiveData

    private val _sortOrdersLiveData = MutableLiveData<Array<Query.SortOrder>>()

    val audios: LiveData<Array<Media>>
        get() = _audios

    private val _audios = MutableLiveData<Array<Media>>(arrayOf())

    val videos: LiveData<Array<Media>>
        get() = _videos

    private val _videos = MutableLiveData<Array<Media>>(arrayOf())

    val playlists: LiveData<Array<Playlist>>
        get() = _playlists

    private val _playlists = MutableLiveData<Array<Playlist>>(arrayOf())

    val mediaFailedMsg: LiveData<String?>
        get() = _mediaFailedMsg

    private val _mediaFailedMsg = MutableLiveData<String?>(null)

    val audiosIsLoading: LiveData<Boolean>
        get() = _audiosIsLoading

    private val _audiosIsLoading = MutableLiveData<Boolean>(false)

    val videosIsLoading: LiveData<Boolean>
        get() = _videosIsLoading

    private val _videosIsLoading = MutableLiveData<Boolean>(false)

    fun fetchMedias(uri: Uri,
                    projection: Array<String>,
                    sortByColumns: Array<String> = arrayOf(),
                    sortOrders: Array<Query.SortOrder> = arrayOf(),
                    contentResolver: ContentResolver) {

        fetchAudios(uri, projection, sortByColumns, sortOrders, contentResolver)
        fetchVideos(uri, projection, sortByColumns, sortOrders, contentResolver)
    }

    private fun fetchAudios(uri: Uri,
                    projection: Array<String>,
                    sortByColumns: Array<String> = arrayOf(),
                    sortOrders: Array<Query.SortOrder> = arrayOf(),
                    contentResolver: ContentResolver) {

        if (_audiosIsLoading.value!!) {
            return
        }

        viewModelScope.launch {

            _audiosIsLoading.value = true

            when(val result = repository.fetchAudios(uri, projection, sortByColumns, sortOrders, contentResolver)) {
                is IRepository.FetchMedias.Success -> {
                    _audios.value = result.data
                }

                is IRepository.FetchMedias.Failed -> {
                    _mediaFailedMsg.value = result.msg
                    cancel()
                }
            }

            _audiosIsLoading.value = false
        }
    }

    private fun fetchVideos(uri: Uri,
                    projection: Array<String>,
                    sortByColumns: Array<String> = arrayOf(),
                    sortOrders: Array<Query.SortOrder> = arrayOf(),
                    contentResolver: ContentResolver) {

        if (_videosIsLoading.value!!) {
            return
        }

        viewModelScope.launch {

            _videosIsLoading.value = true

            when(val result = repository.fetchVideos(uri, projection, sortByColumns, sortOrders, contentResolver)) {
                is IRepository.FetchMedias.Success -> {
                    _videos.value = result.data
                }

                is IRepository.FetchMedias.Failed -> {
                    _mediaFailedMsg.value = result.msg

                    cancel()
                }
            }

            _audiosIsLoading.value = false
        }
    }
}

ViewModelProvider.Factory

建立了 ViewModel,我們還需要一個 ViewModelProvider.Factory 來記錄這些 ViewModel:

@Suppress("UNCHECKED_CAST")
class RepositoryViewModelFactory(val application: Application): ViewModelProvider.NewInstanceFactory() {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {


        return with(modelClass) {
            when {
                isAssignableFrom(MainActivityViewModel::class.java) -> MainActivityViewModel((application as SMPApplication).repository)
                else -> super.create(modelClass)
            }
        } as T
    }
}

成功取得資料

通過這些設定,我們便可以在 MainActivity 中 fetch 並監視資料的更新:

class MainActivity : AppCompatActivity() {
    private lateinit var _viewModel: MainActivityViewModel
    private lateinit var _binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        _binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(_binding.root)

        _viewModel = RepositoryViewModelFactory(application).create(MainActivityViewModel::class.java)

        _viewModel.audiosIsLoading.observe(this) {

        }

        _viewModel.audios.observe(this) {

        }

        _viewModel.videos.observe(this) {

        }
    }

    override fun onStart() {
        super.onStart()
        val allowed = PermissionUtils.requestStoragePermission(this)

        if (allowed) {
            _viewModel.fetchMedias(
                MediaStore.Files.getContentUri("external"),
                Media.getProjection(),
                Media.sortByDate(),
                arrayOf(Query.SortOrder.DESC),
                contentResolver)
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {

        when (requestCode) {
            PermissionUtils.STORAGE_REQUEST_CODE -> PermissionUtils.handleStoragePermissionResult(
                permissions,
                grantResults,
                this
            )

            else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        }

    }
}

Error

Gradle

Java version

Execution failed for task ':app:kaptGenerateStubsDebugKotlin'.
> 'compileDebugJavaWithJavac' task (current target is 1.8) and 'kaptGenerateStubsDebugKotlin' task (current target is 17) jvm target compatibility should be set to the same Java version.

我們只需要將 kotlin options 與 Compile Option 中的 Java Target 從 1.8 改為 17

compileOptions {
    sourceCompatibility JavaVersion.VERSION_17
    targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
    jvmTarget = '17'
}

Reference

<無> # Next 下一篇:








無>

About Post

Search

    Table of Contents