Android 10/11以降のScoped Storage対応完全ガイド|Xamarin/.NETでユーザーが見える保存先と旧データ移行の実装

Android 10以降のScoped Storage移行で、従来の /storage/emulated/0/MyApp 直下に自由保存していたXamarin(.NET for Android / Xamarin.Android)アプリが動かなくなった――そんな相談が増えています。本記事は「ユーザがファイルマネージャで確認できる保存先を確保しつつ、旧バージョンのデータも扱う」ための現実的な設計・実装・移行手順を、エンジニア視点で徹底解説します。

目次

Android 10以降で /storage/emulated/0/MyApp へ保存できなくなった問題

質問概要

  • Xamarin(.NET)アプリで Android.OS.Environment.ExternalStorageDirectory.AbsolutePath を使い、/storage/emulated/0/MyApp 配下にフォルダとファイルを作成していた。
  • Android 10(API 29) では android:requestLegacyExternalStorage="true" を Manifest に追加する回避策があったが、Android 11(API 30) 以降は効かなくなった。
  • ユーザがファイルマネージャで確認できる保存先を確保しつつ、旧バージョンのデータも扱うにはどうすればよいか。

結論(要点だけ先に)

  • 内部保存は FileSystem.AppDataDirectory(Xamarin.Essentials / .NET MAUI Essentials)に集約。 権限不要・バックアップ対象・セキュア。
  • ユーザに見せる保存は2択:
    a) 自動保存したいなら MediaStore.Downloads + RelativePath = "Download/MyApp"
    b) 保存場所をユーザに選ばせるなら SAF(Storage Access Framework)ACTION_OPEN_DOCUMENT_TREE/ACTION_CREATE_DOCUMENT
  • 旧パスのデータ移行は「直接読めるなら即コピー」「読めないならフォルダピッカーでユーザ承認→コピー」の二段構え。
  • MANAGE_EXTERNAL_STORAGE(いわゆる「全ファイルアクセス」)は原則使わない。 Play審査で落ちやすい。回避設計が最善。
  • ギャラリー非表示が必要なら .NOMEDIA を新フォルダに配置。

Scoped Storageの基本とバージョン別挙動

Android 11(API 30)以降をターゲット(targetSdkVersion >= 30)にすると、旧来の「外部ストレージ直下へ自由にパス書き込み」は封じられます。アプリは以下のいずれかの枠組みで保存します。

要件推奨保存先 / APIユーザ可視性主な注意点
アプリ内部データ、設定、キャッシュFileSystem.AppDataDirectory / FileSystem.CacheDirectory不可視(他アプリから不可)権限不要。バックアップ対象(noBackupで除外可)。
ユーザに見える汎用ファイル(自動保存)MediaStore.Downloads(例:Download/MyApp可視(Filesアプリで閲覧・共有可)パスではなくコンテンツURIRelativePathでサブフォルダ指定。
ユーザが保存場所を選びたいSAFACTION_OPEN_DOCUMENT_TREE / ACTION_CREATE_DOCUMENT可視(任意のフォルダ)最初にユーザがフォルダ/ファイルを選択。永続許可を付与して継続利用。
画像・動画・音声などメディアMediaStore(Images/Video/Audio各コレクション)可視(ギャラリー等)読取権限はOSに依存(API33+はREAD_MEDIA_*)。
旧来の外部パス直下(/storage/emulated/0/MyApp非推奨(移行対象)可視API30+では直接書込不可。読み取りも制限。SAFで移行。

実装ガイド(Xamarin/.NET for Android)

内部保存:FileSystem.AppDataDirectory を最優先

機密性やバックアップ、速度面で内部保存が第一候補です。Xamarin.Essentials(あるいは .NET MAUI Essentials)の FileSystem を使えば、プラットフォーム差異を気にせず共通的に扱えます。

using System.IO;
using Xamarin.Essentials;
// .NET MAUI の場合は Microsoft.Maui.Storage

string appDir = FileSystem.AppDataDirectory;
string path = Path.Combine(appDir, "settings.json");
File.WriteAllText(path, "{{ "theme": "dark" }}");
  • ユーザや他アプリからは直接見えません。
  • アプリのバックアップ対象(除外したい場合は noBackup ディレクトリやバックアップ設定で制御)。
  • 一時ファイルは FileSystem.CacheDirectory を使用。

ユーザ可視の自動保存:MediaStore.Downloads(Download/MyApp

ユーザがファイルアプリで簡単に見つけられ、アプリからは無許可で(原則)書き込み可能な「現実解」が Downloads コレクションです。相対パスでサブフォルダを切り、MyApp配下に整理します。

using Android.Content;
using Android.OS;
using Android.Provider;
using System.IO;
using Android.Net;
using System.Threading.Tasks;

public async Task SaveToDownloadsAsync(Context ctx, string fileName, string mimeType, Stream data)
{
var values = new ContentValues();
values.Put(MediaStore.IMediaColumns.DisplayName, fileName);
values.Put(MediaStore.IMediaColumns.MimeType, mimeType);


// API29+ : Download/MyApp サブフォルダに配置
if (Build.VERSION.SdkInt >= BuildVersionCodes.Q)
    values.Put(MediaStore.IMediaColumns.RelativePath, "Download/MyApp");

Uri collection = MediaStore.Downloads.ExternalContentUri;
Uri itemUri = ctx.ContentResolver.Insert(collection, values);

using (var outStream = ctx.ContentResolver.OpenOutputStream(itemUri))
{
    await data.CopyToAsync(outStream);
    await outStream.FlushAsync();
}
return itemUri; // コンテンツURIをDB等に保持


}

ポイント

  • 戻り値は ファイルパスではなくコンテンツURI。以後の参照はURIで行います。
  • Downloadsは汎用ファイルの保存先としてユーザ理解が容易。共有もスムーズ。
  • ギャラリーに出したくない場合は後述の .NOMEDIA も検討。

ユーザに保存場所を選ばせる:SAF(フォルダ/ファイルピッカー)

「ドキュメント/任意フォルダに置きたい」「SDカードを選びたい」などユーザ選択を取り込むなら、Storage Access Framework を利用します。アプリが直接パスで書くのではなく、ユーザが選んだツリーURIに対し書き込みます。

フォルダ選択(ツリー)と永続許可

// 起動側(Activity)
const int RequestPickDir = 1001;
var intent = new Intent(Intent.ActionOpenDocumentTree);
intent.AddFlags(ActivityFlags.GrantReadUriPermission
              | ActivityFlags.GrantWriteUriPermission
              | ActivityFlags.GrantPersistableUriPermission);
StartActivityForResult(intent, RequestPickDir);

// コールバック
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
base.OnActivityResult(requestCode, resultCode, data);
if (requestCode == RequestPickDir && resultCode == Result.Ok)
{
var treeUri = data.Data;
var takeFlags = data.Flags & (ActivityFlags.GrantReadUriPermission | ActivityFlags.GrantWriteUriPermission);
ContentResolver.TakePersistableUriPermission(treeUri, takeFlags);


    // AndroidX DocumentFile 経由でツリー配下を操作
    var picked = AndroidX.DocumentFile.Provider.DocumentFile.FromTreeUri(this, treeUri);

    // MyApp サブフォルダが無ければ作る
    var appDir = picked.FindFile("MyApp") ?? picked.CreateDirectory("MyApp");

    // ファイル作成
    var file = appDir.CreateFile("text/plain", "example.txt");
    using var outStream = ContentResolver.OpenOutputStream(file.Uri);
    using var writer = new StreamWriter(outStream);
    writer.WriteLine("Hello SAF");
}


}

単発の保存ダイアログ(ファイル作成)

// ユーザにファイル名を決めてもらう
const int RequestCreateDoc = 1002;
var intent = new Intent(Intent.ActionCreateDocument);
intent.SetType("application/pdf");
intent.PutExtra(Intent.ExtraTitle, "report.pdf");
intent.AddFlags(ActivityFlags.GrantWriteUriPermission);
StartActivityForResult(intent, RequestCreateDoc);

// コールバックでURIを受け取り書き込む
protected override void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
base.OnActivityResult(requestCode, resultCode, data);
if (requestCode == RequestCreateDoc && resultCode == Result.Ok)
{
var uri = data.Data;
using var outStream = ContentResolver.OpenOutputStream(uri);
// 任意のStreamからコピー
using var src = GeneratePdfReport(); // 例:PDFを生成したStream
src.CopyTo(outStream);
}
}

SAFを使うメリット

  • ユーザが保存先を決定できる(内部/外部/SD/クラウド提供者など)。
  • 一度ツリー許可を取れば永続的に読み書き可能(再起動後も TakePersistableUriPermission 済みなら可)。
  • Google Play審査と相性が良い(不要な広範権限を求めない)。

非推奨APIの扱い:GetExternalStoragePublicDirectory()

以下のように「公開ドキュメントフォルダ直下に書く」従来のやり方は、API29以降非推奨です。Android 9以前の互換用途に限定し、10以降はMediaStoreSAFに切り替えます。

// < Android 10 の端末にだけ条件分岐して使用(例)
if (Build.VERSION.SdkInt < BuildVersionCodes.Q)
{
    var publicDocs = Android.OS.Environment
        .GetExternalStoragePublicDirectory(Android.OS.Environment.DirectoryDocuments);
    var appFolder = Path.Combine(publicDocs.AbsolutePath, "MyApp");
    Directory.CreateDirectory(appFolder);
    File.WriteAllText(Path.Combine(appFolder, "legacy.txt"), "Legacy OK");
}

旧パス(/storage/emulated/0/MyApp)からのデータ移行

すでにユーザ端末に旧パスのデータがある場合、初回起動時に検出→新場所へコピーします。API30+では旧パスに直接アクセスできない可能性が高いため、二段階で実装します。

段階1:直接読めるならそのままコピー

string oldRoot = Path.Combine(
    Android.OS.Environment.ExternalStorageDirectory.AbsolutePath, "MyApp");

// 新保存先(例:Downloads/MyApp に移行)
async Task MigrateLegacyIfAccessibleAsync(Context ctx)
{
try
{
if (Directory.Exists(oldRoot))
{
foreach (var f in Directory.GetFiles(oldRoot))
{
using var input = File.OpenRead(f);
var fileName = Path.GetFileName(f);
// MediaStore.Downloadsへ保存(上記関数を再利用)
var _ = await SaveToDownloadsAsync(ctx, fileName, "application/octet-stream", input);
}
// 必要に応じて削除(ユーザ同意を取る)
// Directory.Delete(oldRoot, true);
}
}
catch (UnauthorizedAccessException)
{
// 次の段階へ(SAFでフォルダ選択を促す)
PromptPickLegacyFolderWithSAF();
}
}

段階2:読めない場合はSAFで旧フォルダをユーザに選んでもらう

// 旧フォルダ(MyApp)をユーザに選択させる
const int RequestPickLegacy = 2001;
var intent = new Intent(Intent.ActionOpenDocumentTree);
intent.AddFlags(ActivityFlags.GrantReadUriPermission
              | ActivityFlags.GrantPersistableUriPermission);
StartActivityForResult(intent, RequestPickLegacy);

// コールバック内でツリー配下のファイルを列挙してコピー
protected override async void OnActivityResult(int requestCode, Result resultCode, Intent data)
{
base.OnActivityResult(requestCode, resultCode, data);
if (requestCode == RequestPickLegacy && resultCode == Result.Ok)
{
var treeUri = data.Data;
var takeFlags = data.Flags & ActivityFlags.GrantReadUriPermission;
ContentResolver.TakePersistableUriPermission(treeUri, takeFlags);


    var dir = AndroidX.DocumentFile.Provider.DocumentFile.FromTreeUri(this, treeUri);
    foreach (var child in dir.ListFiles())
    {
        if (child.IsFile)
        {
            using var input = ContentResolver.OpenInputStream(child.Uri);
            var name = child.Name ?? "unknown.bin";
            await SaveToDownloadsAsync(this, name, "application/octet-stream", input);
        }
    }
}


}

移行時のユーザ体験Tips

  • 最初に「保存仕様が変わりました。1回だけ移行します」と明示。
  • 対象ファイルと容量を事前に見積り表示し、進捗バーを出す。
  • 成功後に新保存先(Downloads/MyApp など)をトーストで案内。

権限設計(最小権限の原則)

OS/API内部保存MediaStore(Downloads)書込SAF(ユーザ選択)注意
Android 10+(API29+)不要基本不要(コンテンツURI経由)ユーザの選択操作が必須旧のWRITE_EXTERNAL_STORAGEは非推奨
Android 13+(API33+)不要不要不要(選択時に付与)メディア読取は READ_MEDIA_* に分割
Android 9以下不要WRITE_EXTERNAL_STORAGE が必要な場合あり利用可互換用にのみ旧APIを条件分岐

MANAGE_EXTERNAL_STORAGE はファイルマネージャ等の特殊用途以外は原則不可。Play審査で却下されるリスクが高いので避けましょう。

.NOMEDIA の扱い(ギャラリー非表示)

ドキュメントやアプリ生成ファイルをギャラリー類に表示したくない場合、保存先直下に .NOMEDIA を配置します。MediaStore.Downloads 配下に作る場合も、ファイルそのものをMediaStore経由で作成するのが確実です。

public async Task CreateNoMediaAsync(Context ctx)
{
    var values = new ContentValues();
    values.Put(MediaStore.IMediaColumns.DisplayName, ".NOMEDIA");
    values.Put(MediaStore.IMediaColumns.MimeType, "application/octet-stream");
    if (Build.VERSION.SdkInt >= BuildVersionCodes.Q)
        values.Put(MediaStore.IMediaColumns.RelativePath, "Download/MyApp");


var uri = ctx.ContentResolver.Insert(MediaStore.Downloads.ExternalContentUri, values);
ctx.ContentResolver.OpenOutputStream(uri).Dispose(); // 空ファイルを作る


}

共有・エクスポートの設計

ファイルを他アプリへ渡す際は、Shareシート(Xamarin.Essentials/MAUI Essentials の Share API)やFileProviderを使います。MediaStoreのURIはそのまま共有できますが、内部保存ファイルを共有する場合は一時的なコンテンツURIを発行します。

using Xamarin.Essentials;
await Share.RequestAsync(new ShareTextRequest
{
    Title = "Share link",
    Text  = "https://example.com"
});

// ファイル共有(内部ファイルを一時公開)
var filePath = Path.Combine(FileSystem.AppDataDirectory, "report.pdf");
await Share.RequestAsync(new ShareFileRequest
{
Title = "Share File",
File  = new ShareFile(filePath)
});

必要に応じて FileProvider の定義をAndroidManifestに追加し、@xml/file_paths を設定してください(Essentialsが自前のProviderを内包していない環境では必須)。

運用設計:バックアップ、命名規則、パフォーマンス

  • バックアップ:内部保存(AppDataDirectory)は標準バックアップの対象。機微データを除外したい場合は android:allowBackup="false" や noBackup ディレクトリを併用。
  • 命名規則:ユーザ可視ファイルは「日時+意味」で衝突回避(例:Report_2025-11-05_103001.pdf)。
  • パフォーマンス:大きなファイルは非同期ストリームで逐次コピーし、UIスレッドを塞がない。進捗を IProgress<long> 等で通知。

よくある落とし穴と対策

  • 「Android/data」配下はユーザから見えないGetExternalFilesDir() はアプリ専用の外部領域だが、Android 11+では多くのファイルマネージャから直接参照できない。ユーザ可視目的には使わない。
  • 絶対パス前提の設計:Scoped Storageでは「パスではなくURI」。アプリ内での参照もURI基盤に切替。
  • URIの永続性:SAFで得たツリーURIは takePersistableUriPermission を忘れず実行。例外時は再同意を促す。
  • 旧権限の残存WRITE_EXTERNAL_STORAGE をManifestに残したままだと審査・動作がややこしくなる。OS別分岐を明確化。

クロスプラットフォーム連携(iOS / Windows / .NET MAUI)

  • 内部保存は FileSystem.AppDataDirectoryFileSystem.CacheDirectory を共通利用。
  • ユーザに渡すファイルは共有シートファイルピッカーでエクスポート。プラットフォームごとの「エクスポートをユーザ操作で完結させる」方針は審査的にも安全。
  • .NET MAUIへ移行する場合も、MediaStore / SAF を使う戦略は同じ。UIレイヤだけ置換し、ストレージ層は流用可能。

実装サンプル(統合ストレージサービス)

実プロジェクトでの再利用を想定した、最小限のストレージサービス例です。URI基盤・OS分岐・移行処理をひとまとめにします。

using Android.Content;
using Android.OS;
using Android.Provider;
using Android.Net;
using AndroidX.DocumentFile.Provider;
using Xamarin.Essentials;
using System.IO;
using System.Threading.Tasks;

public class StorageService
{
private readonly Context ctx;


public StorageService(Context context) => ctx = context;

public string AppDataPath => FileSystem.AppDataDirectory;

public async Task<Uri> SaveUserVisibleAsync(string fileName, string mimeType, Stream source)
{
    // 既定は Downloads/MyApp
    var values = new ContentValues();
    values.Put(MediaStore.IMediaColumns.DisplayName, fileName);
    values.Put(MediaStore.IMediaColumns.MimeType, mimeType);
    if (Build.VERSION.SdkInt >= BuildVersionCodes.Q)
        values.Put(MediaStore.IMediaColumns.RelativePath, "Download/MyApp");

    var uri = ctx.ContentResolver.Insert(MediaStore.Downloads.ExternalContentUri, values);
    using var outStream = ctx.ContentResolver.OpenOutputStream(uri);
    await source.CopyToAsync(outStream);
    await outStream.FlushAsync();
    return uri;
}

public async Task CreateNoMediaAsync()
{
    var values = new ContentValues();
    values.Put(MediaStore.IMediaColumns.DisplayName, ".NOMEDIA");
    values.Put(MediaStore.IMediaColumns.MimeType, "application/octet-stream");
    if (Build.VERSION.SdkInt >= BuildVersionCodes.Q)
        values.Put(MediaStore.IMediaColumns.RelativePath, "Download/MyApp");
    var uri = ctx.ContentResolver.Insert(MediaStore.Downloads.ExternalContentUri, values);
    ctx.ContentResolver.OpenOutputStream(uri).Dispose();
    await Task.CompletedTask;
}

public async Task MigrateFromLegacyIfPossibleAsync()
{
    string legacyPath = Path.Combine(Android.OS.Environment.ExternalStorageDirectory.AbsolutePath, "MyApp");
    if (!Directory.Exists(legacyPath)) return;

    foreach (var file in Directory.GetFiles(legacyPath))
    {
        try
        {
            using var input = File.OpenRead(file);
            var name = Path.GetFileName(file);
            await SaveUserVisibleAsync(name, "application/octet-stream", input);
        }
        catch (UnauthorizedAccessException)
        {
            // アクセスできない場合は呼び出し側でSAFにフォールバック
            throw;
        }
    }
}

public async Task CopyFromSAFTreeAsync(Uri treeUri)
{
    var doc = DocumentFile.FromTreeUri(ctx, treeUri);
    foreach (var child in doc.ListFiles())
    {
        if (child.IsFile)
        {
            using var input = ctx.ContentResolver.OpenInputStream(child.Uri);
            var name = child.Name ?? "unknown.bin";
            await SaveUserVisibleAsync(name, "application/octet-stream", input);
        }
    }
}


}

テストチェックリスト

  • APIレベル別に実機/エミュレータで検証(29, 30, 33+)。
  • 初回起動で旧フォルダがある/ない両方のケースをテスト。
  • SAFでSDカード・内部ストレージの双方を選んでコピー精度を確認。
  • MediaStore(Downloads)に保存後、Filesアプリで「Download/MyApp」配下に見えること。
  • ギャラリーに不要表示が出ない(.NOMEDIA 動作確認)。
  • 共有機能(Shareシート)で添付・他アプリ受け渡しが正常。
  • 大量ファイル移行時の進捗・中断再開・エラー復旧。

設計判断の早見表

シナリオおすすめ理由
アプリが自動で成果物を保存し、ユーザに見える方が良いMediaStore.Downloads / Download/MyApp権限最小で可視性が高く、共有も容易。審査にも優しい。
ユーザが自由に保存先を決めたいSAF(ツリー選択+永続許可)任意のフォルダ/SD/クラウド提供者を選択可能。
設定・DB・キャッシュ等の内部データAppDataDirectory / CacheDirectoryセキュアで高速。バックアップ制御もしやすい。
既存の /storage/emulated/0/MyApp をどうにか読みたい可能なら直接コピー、不可ならSAFでユーザ選択API30+では直接アクセスが制限されるため。

まとめ

  • 内部保存=AppDataDirectory、ユーザ共有=Downloads/MyAppまたはSAFという二段構えが最も現実的。
  • requestLegacyExternalStorage 前提の実装は早急に廃止。Scoped Storage設計へ舵を切る。
  • MANAGE_EXTERNAL_STORAGEなしで完結する構成にし、Google Play審査リスクを回避。
  • 旧データは自動コピー→必要時SAFで補助のハイブリッド移行。
  • URI基盤・最小権限・ユーザ操作を活用すれば、UXとセキュリティ、審査適合を同時に満たせます。

付録:よくある質問(FAQ)

Q. 「ユーザから見える」=GetExternalFilesDir() でOK?
A. いいえ。Android/data/<package>/files はAndroid 11+で多くのファイルマネージャから閲覧できません。ユーザ可視を重視するなら MediaStore.DownloadsSAF を使います。

Q. ドキュメントフォルダ(Documents/MyApp)に直接書きたい
A. 旧API(GetExternalStoragePublicDirectory)は非推奨。SAFでユーザにDocuments配下のフォルダを選択してもらうか、Downloads配下に整理するのが安全です。

Q. 画像や動画は?
A. MediaStore.Images / MediaStore.Video / MediaStore.Audio を使います。読取権限はOSに依存しますが、書き込みはコンテンツURI前提で設計します。

Q. なぜ「ファイルパス」ではなく「URI管理」なの?
A. Scoped Storageでは直接パスは不安定・不許可になりがちです。URIならOSが適切な場所・許可で安全に扱えます。

Q. バックアップ対象を分けたい
A. 構成ファイルは内部保存(バックアップ対象)、一時ファイルはキャッシュ(対象外)、ユーザ可視はDownloads/SAFへ――と役割で置き分けるのが楽です。


実運用テンプレ:移行のフロー文面サンプル

アプリ内表示用の短文テンプレート例です(そのまま使えます)。

  • 初回案内:「保存仕様の変更に伴い、以前のファイルを新しい保存先へ移行します(1回のみ)」。
  • 同意確認:「移行を開始しますか?(キャンセル可)」。
  • 権限要求:「旧フォルダを選択してください(MyApp)」。
  • 進捗表示:「23/120 を移行中… 残り約 2分」。
  • 完了案内:「移行が完了しました。Download/MyApp に保存されています」。

コード断片の再掲(コピペ用ミニリファレンス)

// 内部保存
var app = FileSystem.AppDataDirectory;
File.WriteAllText(Path.Combine(app, "config.json"), "{{}}");

// Downloads/MyApp へ保存(URIで管理)
await SaveToDownloadsAsync(this, "output.txt", "text/plain", new MemoryStream(new byte[]{1,2,3}));

// SAF:フォルダ選択→永続許可→DocumentFileで作成
// (前述のサンプルを参照)

// .NOMEDIA の作成(Downloads/MyApp)
await CreateNoMediaAsync();

// 旧パスの存在確認と移行(直接読めるとき)
await MigrateFromLegacyIfPossibleAsync();

// 直接読めないときはSAFで旧フォルダを選択→CopyFromSAFTreeAsync(treeUri) 

最終チェックポイント

  • 内部/可視/共有の役割分担が明文化されている。
  • URI基盤(コンテンツURI)に移行済みで、絶対パス依存を排除。
  • OS/API別の分岐が最小化され、テスト矩形が明確。
  • 移行UXと失敗時のフォールバック(SAF)が用意されている。
  • 広範権限(MANAGE_EXTERNAL_STORAGE)無しで要件を満たしている。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次