0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【2025年版】Android SDカード/外部ストレージ開発の完全ガイド:Scoped Storage、権限管理、実践パターンを徹底解説

Posted at

はじめに

こんにちは!Android開発を長年やってきて、外部ストレージ周りの変化を肌で感じている筆者です。

SDカード(外部ストレージ)の扱い、本当に複雑になりましたよね。Android 10でScoped Storageが導入されてから、「あれ?今までのコードが動かない!」という経験をした方も多いのではないでしょうか。私も最初は戸惑いましたが、実際に新しいAPIを使ってみると、その便利さと安全性に驚かされました。

この記事では、2025年現在のAndroid開発において、SDカードや外部ストレージを使ったアプリ開発を実践的に解説していきます。特に、実際のプロジェクトで得た知見や、ハマりがちなポイントも含めているので、これから外部ストレージ対応を始める方にとって実用的なガイドになると思います。

なぜSDカードが重要なの?

最近のスマートフォンは内蔵ストレージが大容量になってきましたが、それでも外部ストレージが重要な理由があります。

主要なユースケース

用途 理由
大容量メディア 4K動画や高解像度画像の保存
データバックアップ アプリデータの安全な保管
デバイス間共有 PC-スマホ間でのファイル移動
内部ストレージの負荷軽減 キャッシュやデータオフロードによる内部ストレージの負担軽減

実際にアプリを開発していると、「容量が足りない!」というユーザーからの声をよく聞きます。特に写真・動画系のアプリでは外部ストレージ対応は必須機能になってきていますね。

Android外部ストレージの現状(2025年版)

Scoped Storageの変遷

Android 10で導入されたScoped Storageですが、2025年現在まで着実に進化してきました。

バージョン別変更点

Android Version 主な変更点 影響
Android 10 Scoped Storage導入 オプトアウト可能
Android 11 完全強制適用 必須対応
Android 13 細分化メディア権限 権限見直し必要
Android 14 部分アクセス機能 UX改善
Android 15 セキュリティ強化 追加対応要

SDカード内部ストレージについて

実は知らない人も多いのですが、2025年現在、多くのメーカーがSDカードの内部ストレージ化(Adoptable Storage)を無効化しています。

制限があるメーカー

  • Samsung Galaxy: Galaxy S20以降で無効化
  • Sony Xperia: Xperia 1 III以降で制限
  • Sharp AQUOS: AQUOS R6以降で無効化
  • Xiaomi: MIUI 12以降で制限強化

この変更により、アプリ開発者は「外部ストレージとしての利用」を前提とした設計が必要になりました。内部ストレージ化を前提とした既存アプリの見直しが急務です。

権限管理:Android 13以降の対応

新しい細分化された権限システム

Android 13で大きく変わったのが権限の細分化です。従来の雑なREAD_EXTERNAL_STORAGEとはもうお別れですね。

権限の変更点

従来(Android 12まで) 新しい権限(Android 13以降)
READ_EXTERNAL_STORAGE READ_MEDIA_IMAGES
READ_EXTERNAL_STORAGE READ_MEDIA_VIDEO
READ_EXTERNAL_STORAGE READ_MEDIA_AUDIO

Googleは「写真だけ必要なアプリが、なぜ音楽ファイルにもアクセスできるの?」という疑問から、よりセキュアな権限システムを作りました。ユーザーにとってもわかりやすくなったのは嬉しい変更ですね。

<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

<!-- 後方互換性のため(Android 12以下) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" 
    android:maxSdkVersion="32" />

権限リクエストの実装

実際に書いてみると意外と面倒な権限リクエストですが、一度理解すると簡単です。

バージョン別に適切な権限をリクエストする実装例です:

class MediaPermissionManager(private val activity: FragmentActivity) {
    
    private val permissionLauncher = activity.registerForActivityResult(
        ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        handlePermissionResult(permissions)
    }
    
    fun requestMediaPermissions() {
        val permissions = when {
            Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
                // Android 13以降
                listOf(
                    Manifest.permission.READ_MEDIA_IMAGES,
                    Manifest.permission.READ_MEDIA_VIDEO,
                    Manifest.permission.READ_MEDIA_AUDIO
                )
            }
            else -> {
                // Android 12以下
                listOf(Manifest.permission.READ_EXTERNAL_STORAGE)
            }
        }
        
        val missingPermissions = permissions.filter { permission ->
            ContextCompat.checkSelfPermission(activity, permission) != PackageManager.PERMISSION_GRANTED
        }
        
        if (missingPermissions.isNotEmpty()) {
            permissionLauncher.launch(missingPermissions.toTypedArray())
        } else {
            onPermissionsGranted()
        }
    }
    
    private fun handlePermissionResult(permissions: Map<String, Boolean>) {
        val allGranted = permissions.values.all { it }
        if (allGranted) {
            onPermissionsGranted()
        } else {
            // 権限が拒否された場合の処理
            showPermissionExplanationDialog()
        }
    }
    
    private fun onPermissionsGranted() {
        // 権限が許可された後の処理
        Log.d("Permission", "メディア権限が許可されました")
    }
    
    private fun showPermissionExplanationDialog() {
        // ユーザーに権限の必要性を説明するダイアログ
        AlertDialog.Builder(activity)
            .setTitle("権限が必要です")
            .setMessage("アプリの機能を正常に動作させるために、メディアファイルへのアクセス権限が必要です。")
            .setPositiveButton("設定") { _, _ ->
                // アプリの設定画面を開く
                val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
                intent.data = Uri.fromParts("package", activity.packageName, null)
                activity.startActivity(intent)
            }
            .setNegativeButton("キャンセル", null)
            .show()
    }
}

このクラスをBaseActivityに組み込んでおくと、どのActivityからでも簡単に権限チェックができて便利です。

実践的な実装パターン

パターン1: アプリ専用ディレクトリでのファイル操作

最もシンプルで安全な方法です。権限不要で、アンインストール時に自動削除されるのも嬉しいポイントですね。

メリット

  • 権限不要
  • セットアップが簡単
  • セキュア

デメリット

  • アプリ削除時にデータも消える
  • 他アプリからアクセス不可
class AppFileManager(private val context: Context) {
    
    suspend fun saveTextFile(fileName: String, content: String): Result<File> = 
        withContext(Dispatchers.IO) {
            try {
                val file = File(context.getExternalFilesDir(null), fileName)
                file.writeText(content)
                Result.success(file)
            } catch (e: IOException) {
                Result.failure(e)
            }
        }
    
    suspend fun loadTextFile(fileName: String): Result<String> = 
        withContext(Dispatchers.IO) {
            try {
                val file = File(context.getExternalFilesDir(null), fileName)
                if (file.exists()) {
                    Result.success(file.readText())
                } else {
                    Result.failure(FileNotFoundException("ファイルが見つかりません: $fileName"))
                }
            } catch (e: IOException) {
                Result.failure(e)
            }
        }
    
    fun getAvailableSpace(): String {
        val externalDir = context.getExternalFilesDir(null) ?: return "不明"
        val availableBytes = externalDir.freeSpace
        return when {
            availableBytes > 1024 * 1024 * 1024 -> "${availableBytes / (1024 * 1024 * 1024)}GB"
            availableBytes > 1024 * 1024 -> "${availableBytes / (1024 * 1024)}MB"
            else -> "${availableBytes / 1024}KB"
        }
    }
}

このパターンは本当に楽です。設定ファイルやログファイルの保存にはピッタリですね。私もよく使っています。

パターン2: MediaStore APIを使った画像保存

ギャラリーアプリと連携したい場合はこれ一択です。写真や動画をユーザーのギャラリーに保存できます。

メリット

  • ギャラリーアプリから見える
  • システム標準の場所に保存
  • バックアップ対象になる

デメリット

  • 権限が必要な場合がある
  • 削除管理が複雑
class GalleryImageManager(private val context: Context) {
    
    suspend fun saveImageToGallery(
        bitmap: Bitmap,
        displayName: String
    ): Result<Uri> = withContext(Dispatchers.IO) {
        try {
            val contentValues = ContentValues().apply {
                put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
                put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
                put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
                
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    put(MediaStore.MediaColumns.IS_PENDING, 1)
                }
            }
            
            val resolver = context.contentResolver
            val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
                ?: return@withContext Result.failure(IOException("MediaStore エントリの作成に失敗"))
            
            resolver.openOutputStream(uri)?.use { outputStream ->
                if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream)) {
                    throw IOException("画像の保存に失敗")
                }
            }
            
            // Android 10以降はIS_PENDINGを0にして完了をマーク
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                contentValues.clear()
                contentValues.put(MediaStore.MediaColumns.IS_PENDING, 0)
                resolver.update(uri, contentValues, null, null)
            }
            
            Result.success(uri)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun loadRecentImages(limit: Int = 20): List<MediaImage> = 
        withContext(Dispatchers.IO) {
            val images = mutableListOf<MediaImage>()
            val projection = arrayOf(
                MediaStore.Images.Media._ID,
                MediaStore.Images.Media.DISPLAY_NAME,
                MediaStore.Images.Media.DATE_ADDED,
                MediaStore.Images.Media.SIZE
            )
            
            val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC LIMIT $limit"
            
            context.contentResolver.query(
                MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                projection,
                null,
                null,
                sortOrder
            )?.use { cursor ->
                val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
                val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
                val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
                val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
                
                while (cursor.moveToNext()) {
                    val id = cursor.getLong(idColumn)
                    val name = cursor.getString(nameColumn)
                    val dateAdded = cursor.getLong(dateColumn)
                    val size = cursor.getLong(sizeColumn)
                    val uri = ContentUris.withAppendedId(
                        MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id
                    )
                    
                    images.add(MediaImage(id, name, uri, dateAdded, size))
                }
            }
            
            images
        }
    
    data class MediaImage(
        val id: Long,
        val displayName: String,
        val uri: Uri,
        val dateAdded: Long,
        val size: Long
    )
}

IS_PENDINGフラグを使うことで、保存中の画像が他のアプリに見えないようになります。これ、地味に重要な機能なんです。

パターン3: Storage Access Framework (SAF)

ユーザーに自由にファイルを選択してもらいたい場合に最適です。まさに「何でもできる」パターンですね。

メリット

  • ユーザーが自由に場所を選択
  • 高いセキュリティ
  • どんなファイル形式でもOK

デメリット

  • UI/UXが少し複雑
  • 初回利用時に戸惑うユーザーもいる
class DocumentManager(private val activity: FragmentActivity) {
    
    private var onFileSelected: ((Uri) -> Unit)? = null
    private var onFileSaved: ((Uri) -> Unit)? = null
    
    private val filePickerLauncher = activity.registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == Activity.RESULT_OK) {
            result.data?.data?.let { uri ->
                onFileSelected?.invoke(uri)
            }
        }
    }
    
    private val fileSaverLauncher = activity.registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == Activity.RESULT_OK) {
            result.data?.data?.let { uri ->
                onFileSaved?.invoke(uri)
            }
        }
    }
    
    fun openFilePicker(mimeType: String = "*/*", onSelected: (Uri) -> Unit) {
        onFileSelected = onSelected
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = mimeType
        }
        filePickerLauncher.launch(intent)
    }
    
    fun createFile(fileName: String, mimeType: String = "text/plain", onSaved: (Uri) -> Unit) {
        onFileSaved = onSaved
        val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = mimeType
            putExtra(Intent.EXTRA_TITLE, fileName)
        }
        fileSaverLauncher.launch(intent)
    }
    
    suspend fun readFileContent(uri: Uri): Result<String> = withContext(Dispatchers.IO) {
        try {
            activity.contentResolver.openInputStream(uri)?.use { inputStream ->
                Result.success(inputStream.bufferedReader().use { it.readText() })
            } ?: Result.failure(IOException("ファイルを開けませんでした"))
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
    
    suspend fun writeFileContent(uri: Uri, content: String): Result<Unit> = 
        withContext(Dispatchers.IO) {
            try {
                activity.contentResolver.openOutputStream(uri)?.use { outputStream ->
                    outputStream.writer().use { writer ->
                        writer.write(content)
                    }
                }
                Result.success(Unit)
            } catch (e: Exception) {
                Result.failure(e)
            }
        }
}

SAFは最初こそ複雑に見えますが、一度実装すればあらゆるファイル操作に対応できます。エクスポート・インポート機能には欠かせませんね。

まとめ

Scoped Storageの導入で開発は確かに複雑になりました。私自身、最初は「また面倒なことが増えた...」と思っていましたが、実際に使ってみると:

  • セキュリティが向上した
  • ユーザーの信頼を得やすくなった
  • 一度理解すれば、より良いアプリが作れる

特にMediaStore APIとSAFの使い分けができるようになると、ユーザビリティとセキュリティを両立した素晴らしいアプリが開発できます。


参考資料

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?