社内配布(ストア外配布)の .NET MAUI(Android)アプリを、端末上で最新版へ更新したい。APK のダウンロードはできるのに、ダウンロード後の「インストール(更新)」をアプリ内からどう起動すればいいか分からない――本記事では、Android の制約を踏まえた現実的な実装手順と、つまずきやすいポイントをまとめます。
.NET MAUI アプリで「ストア外配布 APK」をアプリ内から更新したいときの結論
最初に結論です。Google Play を使わない社内配布 APK を、一般的な Android アプリ(.NET MAUI を含む)から無操作でサイレント更新することは基本的にできません。できるのは、APK を端末に保存したうえで、Android のインストール画面(パッケージインストーラ)を起動し、ユーザーに確認してもらって更新してもらう方法です。
この「ユーザー操作が必須」という前提を受け入れたうえで、実装として重要になるのがFileProviderとインストール許可(不明なアプリのインストール)の扱いです。ここを押さえれば、社内配布でも“アプリ内から更新導線を作る”ことは十分可能です。
なぜサイレント更新できないのか(Android のセキュリティモデル)
Android は、アプリが勝手に他アプリ(=APK)をインストール・更新してしまうと、マルウェア的な振る舞いが可能になってしまいます。そのため、通常のアプリが OS に対して「黙って上書き更新して」と命令することは制限されています。
例外的に、以下のような“管理された環境”では自動インストールが成立することがありますが、これは一般アプリの範囲を超えます。
- MDM / EMM(Intune など)で管理されている企業端末(管理者側が配布・更新を制御)
- デバイスオーナー / プロファイルオーナーとして動作する管理アプリ(キッティング済み端末や専用端末)
- 一部のメーカー独自機能、またはシステムアプリ相当の権限を持つ環境
「社内端末でも、ユーザーが何も触らずに強制更新したい」という要件がある場合は、後半で紹介するMDM 配布やManaged Google Play(組織内限定配布)の方が現実的です。
実装の全体像(やることを俯瞰する)
ストア外配布 APK の“アプリ内更新”は、ざっくり次の流れで作ります。
| フェーズ | やること | ハマりどころ |
|---|---|---|
| 更新確認 | サーバー/DB から最新バージョン情報を取得し、更新が必要か判定 | versionCode(Android の数値)で比較しないと事故りやすい |
| ダウンロード | APK をアプリのローカル領域(Cache など)へ保存 | 保存場所が外部ストレージだと権限・Scoped Storage で面倒 |
| 検証 | SHA-256 などで改ざんチェック、署名やサイズ確認 | ダウンロード失敗時に「解析エラー」に見える |
| インストール導線 | FileProvider で content:// URI を作り、インストール画面を起動 | file:// を渡すと Android 7+ で例外、権限フラグ不足でも失敗 |
| 許可・案内 | 「この提供元からのインストール」を許可してもらう | Android 8+ はアプリ単位で許可。UI の案内が必須 |
APK をローカルに保存する(.NET MAUI での定番は CacheDirectory)
まず APK を端末へ落とし込みます。おすすめはアプリ専用領域です。とくに .NET MAUI では FileSystem.Current.CacheDirectory が扱いやすく、外部ストレージ権限を避けやすいです。
例として、HTTP で APK を取得してキャッシュに保存する最小コードは次の通りです(実運用では後述の改ざん検知も入れるのが安全です)。
using System.Net.Http;
public static class ApkDownloadService
{
public static async Task<string> DownloadApkAsync(string apkUrl, CancellationToken ct = default)
{
// 保存先(例:キャッシュ)
var apkPath = Path.Combine(FileSystem.Current.CacheDirectory, "app-update.apk");
using var http = new HttpClient();
using var response = await http.GetAsync(apkUrl, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(ct);
await using var output = File.Create(apkPath);
await input.CopyToAsync(output, ct);
return apkPath;
}
}
保存先を CacheDirectory にするメリットは次の通りです。
- 外部ストレージ権限を回避しやすい(Scoped Storage の影響を受けにくい)
- アプリのアンインストールで自動的に消える(端末内に APK が残りにくい)
- FileProvider の
<cache-path>で素直に共有できる
逆に、外部ストレージ(Download フォルダなど)へ保存する設計は、権限や端末差異が増えるので、まずはアプリ専用領域で完結させる方がトラブルが少ないです。
FileProvider を追加する(ストア外 APK インストールの必須パーツ)
Android 7.0(API 24)以降、アプリ間でファイルパス(file://)を直接渡すと例外になるケースが増えました。APK のインストールに限らず、外部へファイルを渡すときはFileProvider で content:// URI に変換して渡すのが定石です。
必要なファイルと配置場所(MAUI の標準構成)
.NET MAUI の Android 側は、一般的に以下の場所へファイルを置きます。
| 項目 | 配置場所(例) | 役割 |
|---|---|---|
| AndroidManifest.xml | Platforms/Android/AndroidManifest.xml | FileProvider 定義・権限宣言 |
| provider_paths.xml | Platforms/Android/Resources/xml/provider_paths.xml | FileProvider が共有してよいパスの定義 |
| インストール起動コード | #if ANDROID ブロック、または Platforms/Android 実装 | Intent 生成と StartActivity |
AndroidManifest.xml に FileProvider を定義する
<application> 配下に FileProvider を追加します。authority はパッケージ名 + 固定文字列にしておくと衝突しにくいです。ここでは ${applicationId}.fileprovider を採用します。
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- APK インストール許可(後述) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
</application>
</manifest>
よくあるミスは次の 2 つです。
- authority がコード側と一致しない(インストール起動時に例外になる)
- android:grantUriPermissions が true でない(読み取り許可が渡せず失敗する)
provider_paths.xml を作成する(CacheDirectory を共有する例)
次に、FileProvider が参照してよいパスを定義します。CacheDirectory を使うなら <cache-path> が最もシンプルです。
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- アプリの内部キャッシュ配下を共有対象にする -->
<cache-path name="cache" path="." />
</paths>
APK をサブフォルダに入れる場合(例:CacheDirectory/apk/app-update.apk)は、path="apk/" のように限定しても構いません。共有範囲は最小化するほど安全です。
APK インストール関連の権限と「不明なアプリのインストール」許可
Manifest に android.permission.REQUEST_INSTALL_PACKAGES を宣言したとしても、端末側で「このアプリからのインストール」を許可しない限り、インストールはブロックされることがあります。特に Android 8.0(API 26)以降はアプリ単位で許可が必要です。
やってはいけない権限:INSTALL_PACKAGES
検索すると android.permission.INSTALL_PACKAGES が出てくることがありますが、これは多くの環境で一般アプリに許可されない(システム権限相当)です。社内配布であっても、普通の署名アプリでは使えない前提で設計した方が安全です。
許可が必要かをチェックし、設定画面へ誘導する
ユーザー体験としては、いきなりインストール画面を出して失敗させるより、事前に「許可が必要です」と案内して設定画面へ誘導した方がスムーズです。Android 8.0+ では、アプリ単位の許可状態を CanRequestPackageInstalls() でチェックできます。
#if ANDROID
using Android.Content;
using Android.OS;
using Android.Provider;
public static class UnknownSourcesHelper
{
public static bool EnsureCanInstallPackages()
{
var context = Android.App.Application.Context;
if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
{
if (!context.PackageManager.CanRequestPackageInstalls())
{
// 「この提供元からのインストール」許可設定画面へ
var intent = new Intent(Settings.ActionManageUnknownAppSources);
intent.SetData(Android.Net.Uri.Parse($"package:{context.PackageName}"));
intent.AddFlags(ActivityFlags.NewTask);
context.StartActivity(intent);
return false;
}
}
return true;
}
}
#endif
この関数を、APK ダウンロード後・インストール起動前のタイミングで呼び出し、false の場合は「設定後にもう一度押してください」と案内するのが現実的です。
Intent でインストール画面を起動する(FileProvider で content:// を渡す)
APK を保存し、FileProvider を設定したら、最後にやることは「インストール UI を起動する」だけです。ポイントは次の 3 つです。
- FileProvider で content:// URI を作る
- MIME タイプは application/vnd.android.package-archive
- GrantReadUriPermission を付ける(相手が APK を読めるようにする)
#if ANDROID
using Android.Content;
using AndroidX.Core.Content;
using Java.IO;
public static class ApkInstaller
{
public static void StartInstall(string apkFilePath)
{
var context = Android.App.Application.Context;
// 不明ソース許可の確認(Android 8+)
if (!UnknownSourcesHelper.EnsureCanInstallPackages())
{
// 設定へ誘導したので一旦終了(ユーザーが戻ってきたら再試行)
return;
}
var apkFile = new File(apkFilePath);
// Manifest の authorities と一致させる
var authority = $"{context.PackageName}.fileprovider";
var uri = FileProvider.GetUriForFile(context, authority, apkFile);
var intent = new Intent(Intent.ActionView);
intent.SetDataAndType(uri, "application/vnd.android.package-archive");
intent.AddFlags(ActivityFlags.GrantReadUriPermission);
intent.AddFlags(ActivityFlags.NewTask);
context.StartActivity(intent);
}
}
#endif
この実装で、ダウンロード済み APK のインストール(既存アプリの更新)画面が起動します。ユーザーが「インストール」や「更新」をタップすると、上書き更新が進みます。
インストール完了をアプリ側で“確実に”検知できる?
更新中はアプリが置き換えられるため、プロセスが再起動したり、直後の状態が不安定になりがちです。一般的には、インストール完了を完璧にトラッキングするよりも、次回起動時にバージョンを再チェックして「更新済み」を表示する方が堅牢です。
.NET MAUI で Android 固有コードをきれいに分離する方法
記事冒頭の通り、#if ANDROID で囲うのが手早い方法です。ただ、規模が大きくなると見通しが悪くなるので、実務ではインターフェース + プラットフォーム実装で分離しておくと運用が楽になります。
共有側:インターフェースを定義する
public interface IAppUpdateInstaller
{
Task CanInstallAsync();
Task StartInstallAsync(string apkFilePath);
}
Android 側:Platforms/Android に実装を置く
#if ANDROID
using Android.Content;
using Android.OS;
using Android.Provider;
using AndroidX.Core.Content;
using Java.IO;
public class AndroidAppUpdateInstaller : IAppUpdateInstaller
{
public Task CanInstallAsync()
{
var context = Android.App.Application.Context;
if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
{
if (!context.PackageManager.CanRequestPackageInstalls())
{
var intent = new Intent(Settings.ActionManageUnknownAppSources);
intent.SetData(Android.Net.Uri.Parse($"package:{context.PackageName}"));
intent.AddFlags(ActivityFlags.NewTask);
context.StartActivity(intent);
return Task.FromResult(false);
}
}
return Task.FromResult(true);
}
public Task StartInstallAsync(string apkFilePath)
{
var context = Android.App.Application.Context;
var file = new File(apkFilePath);
var authority = $"{context.PackageName}.fileprovider";
var uri = FileProvider.GetUriForFile(context, authority, file);
var intent = new Intent(Intent.ActionView);
intent.SetDataAndType(uri, "application/vnd.android.package-archive");
intent.AddFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask);
context.StartActivity(intent);
return Task.CompletedTask;
}
}
#endif
DI 登録して共有コードから呼び出す(例)
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
#if ANDROID
builder.Services.AddSingleton();
#endif
return builder.Build();
}
}
共有側の更新フロー(ダウンロード → インストール誘導)は、IAppUpdateInstaller に対して呼び出すだけになるので、保守しやすくなります。
更新処理の実務的な流れ(おすすめの UI / UX)
社内配布の APK 更新は「技術的にインストール画面を出せる」だけでは不十分で、実際の現場ではユーザーが迷わず更新できる導線が重要です。以下の流れがトラブルになりにくいです。
| ステップ | ユーザーに見せる内容 | 実装ポイント |
|---|---|---|
| 1. 更新通知 | 「新しいバージョンがあります」「更新内容」 | リリースノートをサーバー側で管理すると運用が楽 |
| 2. ダウンロード | 進捗、通信量注意、Wi-Fi 推奨 | 失敗時の再試行、タイムアウト、キャンセル対応 |
| 3. 許可の案内 | 「このアプリからのインストールを許可してください」 | Android 8+ の設定画面へ遷移し、戻ったら再試行 |
| 4. インストール開始 | インストール画面へ遷移することを説明 | FileProvider + Intent。ここで初めて OS UI を起動 |
| 5. 完了確認 | 次回起動時に「更新完了」表示 | 起動時バージョンチェックで判定する方が堅牢 |
改ざん対策と事故防止(社内配布でも必須)
ストア外配布 APK は、配布経路が社内だとしても「配布物=実行コード」です。更新機構をアプリ内に入れるなら、最低限次は押さえておくと安心です。
ダウンロードした APK の SHA-256 を検証する
配布サーバー側で APK の SHA-256 を保持し、端末側で一致確認するだけでも、通信途中の破損や意図しない差し替え事故に強くなります。
using System.Security.Cryptography;
public static class HashUtil
{
public static async Task<string> Sha256Async(string filePath, CancellationToken ct = default)
{
await using var stream = File.OpenRead(filePath);
using var sha = SHA256.Create();
var hash = await sha.ComputeHashAsync(stream, ct);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
実務では、サーバー側で返す JSON に apkUrl と一緒に sha256 を含め、ダウンロード後に一致してからインストールへ進めるのが安全です。
署名(keystore)が違うと更新できない
Android の上書き更新は、基本的に同じ署名で署名された APK でなければ更新できません。社内配布でよく起きるのが、ビルドマシンや担当者変更で keystore が変わり、突然アップデートできなくなる事故です。
運用ルールとして、次を徹底するとトラブルが激減します。
- 本番用の keystore は厳格に管理し、更新担当が変わっても同じ署名で出せる体制にする
- CI/CD(ビルドパイプライン)で署名し、手元ビルド APK を配布しない
- バージョンコード(
versionCode)は単調増加にする(戻すとインストール拒否される)
よくあるエラーと対処(社内配布の現場で詰まりやすい)
ストア外配布 APK の“アプリ内更新”で詰まりやすいポイントを、症状別にまとめます。
| 症状 | 原因として多いもの | 対処 |
|---|---|---|
| インストール画面が開かない/例外が出る | authority 不一致、provider_paths 未配置、FileProvider 未定義 | Manifest の authorities とコードの authority を一致させ、provider_paths.xml の位置を確認 |
| 「このファイルを開けません」「権限がありません」 | GrantReadUriPermission フラグ不足 | ActivityFlags.GrantReadUriPermission を追加 |
| 「インストールがブロックされました」 | 不明なアプリのインストール許可が OFF | Android 8+ の設定画面へ誘導し、許可後に再実行 |
| 「アプリがインストールされませんでした」 | 署名が違う、versionCode が小さい、パッケージ名が違う | 同一 keystore で署名、versionCode を増やす、パッケージ名を同一にする |
| 「解析エラー(パッケージを解析できません)」 | APK 破損、途中で途切れた、対象 CPU/SDK 不一致 | サイズ・ハッシュ検証、再ダウンロード、ABI/SDK のビルド設定確認 |
ストア外配布で“更新”に見せるためのポイント
ユーザーから見ると、Play ストア更新と同じ感覚を期待しがちです。ストア外配布では体験が違うため、アプリ側の説明で不安を減らす工夫が重要です。
- 更新ボタンの前に注意書き:「インストール画面が開きます」「完了後にアプリを再起動してください」など
- 権限許可の説明をスクリーンショット付きで用意(社内 Wiki でも可)
- 更新対象の判定を厳密に:バージョン名だけでなく、内部の versionCode 相当を採用
- 失敗時のリカバリ導線:再ダウンロード、サポート連絡、端末再起動など
サイレント更新が本当に必要なら:一般アプリの範囲を超えた選択肢
「ユーザーが触らなくても強制更新したい」「起動したら必ず最新にしたい」という要件は、ストア外配布 APK をアプリだけで完結させるのが難しい領域です。その場合は、最初から配布の仕組みを変える方が、長期的にコストと事故を減らせます。
| 選択肢 | 向いているケース | 特徴 |
|---|---|---|
| MDM / EMM(Intune など) | 端末を企業が管理できる(社給端末、キオスク端末) | 管理者側で配布・更新を制御しやすい。ユーザー操作を最小化できる |
| Managed Google Play(組織内限定配布) | Google Play を「社内限定」で使える組織 | Play の仕組みで更新が回る。In-app update などの導線も取りやすい |
| デバイスオーナー/専用管理アプリ | 専用端末・現場端末で強い制御が必要 | 運用難度は上がるが、最も自動化しやすい |
“とりあえずアプリだけで更新したい”段階では、本記事の FileProvider + インストール画面起動が最短です。一方で、更新頻度が高い・対象台数が多い・夜間に自動更新したいといった状況では、早めに MDM を検討した方が総コストが下がることが多いです。
まとめ:.NET MAUI で社内配布 APK を更新する最短ルート
ストア外配布の APK を .NET MAUI アプリ内から更新したい場合、実装の核心は次の通りです。
- APK を FileSystem.Current.CacheDirectory などのローカルへ保存する
- FileProvider を Manifest に追加し、provider_paths.xml を用意する
- REQUEST_INSTALL_PACKAGES を宣言し、Android 8+ では許可画面へ誘導できるようにする
- Intent.ActionView でインストール画面を起動し、ユーザーに更新してもらう
- 改ざん・破損対策として、ハッシュ検証や署名運用を整える
これらを押さえれば、Google Play に公開していない社内配布アプリでも、アプリ内に“更新ボタン”を用意し、ユーザーが迷わず最新版へ移行できる導線を作れます。

コメント