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アプリで閲覧・共有可) | パスではなくコンテンツURI。RelativePathでサブフォルダ指定。 |
| ユーザが保存場所を選びたい | SAF(ACTION_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以降はMediaStoreかSAFに切り替えます。
// < 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.AppDataDirectoryとFileSystem.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.Downloads か SAF を使います。
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)無しで要件を満たしている。

コメント