.NET MAUI 8 AndroidのAPK自動更新:サーバー配布で「バージョンが更新できない」原因とFileProvider実装・target APIの考え方

.NET MAUI 8で作ったAndroidアプリをサーバー配布APKで自動更新したいのに、「アプリのバージョンが更新できない」と感じるケースは珍しくありません。結論から言うと、バージョンは実行中に書き換えられず、正しくビルド・署名・権限・FileProvider設定を揃えて“新しいAPKに置き換える”のが唯一の正攻法です。

目次

今回の構成を整理(サーバー配布APKの“アプリ内更新”とは)

ここでいう「アプリ内更新」は、Playストアの更新機構ではなく、アプリがサーバー上の update.json を参照し、更新があればAPKをダウンロードして、端末のパッケージインストーラー画面を起動して入れ替える方式です。配布はWebサイト・社内サーバー・MDMなどを想定します。

この方式は実現できますが、OSのセキュリティ設計上「ユーザーの操作なしに完全サイレントで更新」は基本できません(例外はMDM/デバイスオーナー等の管理下に置いた端末運用)。そのため、UXとしては「更新通知 → ダウンロード → インストール画面へ誘導 → 完了後に再起動(再起動=アプリを開き直す)」が現実的な落としどころです。

「アプリのバージョンが更新できない」の正体

アプリの“バージョン番号”は実行中に書き換えられない

Androidのバージョン情報(versionCode / versionName)は、APKにビルド時に取り込まれて端末にインストールされる「パッケージのメタ情報」です。アプリ実行中に“自分自身のversionCode/versionNameを書き換える”ことはできません。

さらにAndroidは、versionCode が現在インストールされているものより小さいAPKのインストール(=ダウングレード)を防ぐ仕組みを持っています。つまり、更新を成立させるには「新しいAPKの versionCode を必ず増やす」必要があります。

更新判定に使うべきは原則「versionCode(整数)」

「1.10」と「1.2」を文字列として比較すると誤判定が起きるなど、versionName(文字列)は更新判定に不向きです。更新判定の“唯一の真実”は versionCode(整数)に寄せ、ユーザー表示だけ versionName を使うのが安定します。

.NET MAUI 8でversionCode/versionNameを正しく扱う(混乱しがちな対応表)

概念Android側.NET MAUI(.csproj)アプリから参照用途
内部バージョンandroid:versionCode(整数)<ApplicationVersion>(整数)AppInfo.Current.BuildString更新判定・ダウングレード防止
表示用バージョンandroid:versionName(文字列)<ApplicationDisplayVersion>(文字列)AppInfo.Current.VersionStringユーザー表示・リリースノート

.NET MAUIでは、アプリ情報はAndroidManifest由来で取得され、BuildString が versionCode、VersionString が versionName に対応します。

また、MSBuild側の対応関係として ApplicationVersion が android:versionCode、ApplicationDisplayVersion が android:versionName にマップされることが明示されています。

.csprojでの設定例(リリースごとに増やす)

&lt;PropertyGroup&gt;
  &lt;ApplicationVersion&gt;10002&lt;/ApplicationVersion&gt;
  &lt;ApplicationDisplayVersion&gt;1.0.2&lt;/ApplicationDisplayVersion&gt;
&lt;/PropertyGroup&gt;

重要なのは、ApplicationVersion を「毎回必ず増やす」ことです。これが増えていないと、端末は更新として受け付けません。

“自動更新”の運用をラクにするversionCode設計

実務では、次のどれかに寄せると事故が減ります。

  • CIのビルド番号をそのままversionCodeにする(単調増加が保証しやすい)
  • 年/月/連番のような固定桁ルールにする(例:YYMMNN)
  • セマンティックバージョン(1.2.3)とは別に、versionCodeは単調増加の整数として割り切る

versionNameは「人が読むため」の表記(1.2.3など)にして、versionCodeは「機械が比較するための整数」と割り切るのが安定です。

サーバー配布なら“APK形式”が前提(AABのままだと詰む)

.NET MAUIのAndroidリリースビルドは既定でAABになりやすい一方、サーバー配布(ad-hoc)ではAPK形式が必要です。Visual Studioの設定でReleaseのパッケージ形式をAPKに切り替えてから配布・更新フローを組み立てます。

ここを見落とすと「サーバーに置いたファイルはあるのに、端末側でインストールできない/そもそも配れない」状態になります。更新機構の前に、配布物がAPKであることを必ず確認してください。

update.jsonはこう作る(比較・検証・運用のための現実解)

最低限「versionCode」「apkUrl」があれば更新はできますが、運用でハマらないためには改ざん・破損検知と互換性管理のフィールドを持たせるのがおすすめです。

キー型例目的
versionCodenumber10002更新判定の主キー(必須)
versionNamestring1.0.2画面表示・ログ(任意)
apkUrlstringhttps://example.com/app/app-10002.apkAPK取得先(必須)
sha256string(ハッシュ文字列)改ざん/破損検知(推奨)
minSupportedVersionCodenumber10000強制更新ライン(互換性が壊れた時に有効)
notesstring不具合修正と安定性改善更新理由の提示(離脱防止)

update.json例

{
  "versionCode": 10002,
  "versionName": "1.0.2",
  "apkUrl": "https://example.com/downloads/myapp-10002.apk",
  "sha256": "3a0f...(省略)...9b",
  "minSupportedVersionCode": 10000,
  "notes": "クラッシュ修正、通信安定性の改善"
}

ポイントは、アプリ側の現在値を AppInfo.Current.BuildString(=versionCode)で取得し、update.json の versionCode と比較することです。

アプリ内更新の全体フロー(失敗しない順番)

  • サーバーからupdate.json取得(キャッシュに注意)
  • 端末のversionCode(BuildString)と比較
  • 更新が必要ならAPKをダウンロード(内部ストレージ推奨)
  • SHA-256などで破損・改ざん検知
  • 「提供元不明アプリ」の許可状態を確認(Android 8+はアプリ単位)
  • FileProviderでcontent:// URIを生成してインストーラー起動
  • ユーザーがインストール完了 → アプリを開き直す

順番を入れ替えると、インストーラーに渡すURIが無効だったり、権限でブロックされたり、更新判定だけが先走ってUXが崩れたりします。

Android側の要:FileProviderでAPKを渡す

Androidでは、別アプリ(パッケージインストーラー)へファイルを渡すとき、ファイルパスそのものではなく「content URI」で安全に共有するのが基本です。そのために FileProvider を使い、共有できるディレクトリをXMLで定義します。

file_paths.xml(APKを置く場所を宣言)

例として、ダウンロードしたAPKを FileSystem.CacheDirectory(内部キャッシュ)に保存する設計にします。FileProvider側も <cache-path> を使うと整合が取れます。

&lt;?xml version="1.0" encoding="utf-8"?&gt;
&lt;paths xmlns:android="http://schemas.android.com/apk/res/android"&gt;
  &lt;cache-path name="apk_cache" path="." /&gt;
&lt;/paths&gt;

FileProviderの共有ディレクトリはXMLで指定し、後からコードで動的に追加できない点も押さえておくとデバッグが速くなります。

AndroidManifest.xml(provider登録)

&lt;provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true"&gt;
  &lt;meta-data
      android:name="android.support.FILE_PROVIDER_PATHS"
      android:resource="@xml/file_paths" /&gt;
&lt;/provider&gt;

FileProviderの宣言では、authority(例:com.example.myapp.fileprovider)とXMLファイル指定が必要です。Android公式ドキュメントにも同様の形が示されています。

MAUIでは、通常 Platforms/Android/AndroidManifest.xml と Platforms/Android/Resources/xml/file_paths.xml のようにAndroid配下に置いて管理します(プロジェクト構成により置き場所は多少前後します)。

インストール許可(提供元不明アプリ)の扱い:Android 8+で挙動が変わる

Android 8(Oreo)以降は「提供元不明アプリのインストール許可」が端末全体の1スイッチではなく、“どのアプリに許可するか”という単位で管理されます。更新フローの途中でユーザーを設定画面へ誘導できるようにしておくのが実務上必須です。

また、アプリが他のアプリをインストールさせる用途を持つ場合、manifestに REQUEST_INSTALL_PACKAGES を宣言し、許可状態を canRequestPackageInstalls() で事前確認する流れが推奨されています。

Android側ヘルパー実装例(.NET MAUI / Platforms/Android)

まず共有プロジェクト側に、プラットフォーム実装を呼び出すためのpartialを用意します。

// Shared project
public static partial class ApkInstaller
{
    public static partial Task&lt;bool&gt; EnsureInstallPermissionAsync();
    public static partial Task LaunchInstallAsync(string apkFilePath);
}

次にAndroid実装(例:Platforms/Android/ApkInstaller.android.cs)を追加します。

#if ANDROID
using Android.Content;
using Android.Provider;
using AndroidX.Core.Content;

public static partial class ApkInstaller
{
public static partial Task EnsureInstallPermissionAsync()
{
var context = Android.App.Application.Context;


    // Android 8未満は端末全体の設定のため、ここでは「true」として進める設計にする例
    if ((int)Android.OS.Build.VERSION.SdkInt < 26)
        return Task.FromResult(true);

    // Android 8+ はアプリごとの許可
    if (context.PackageManager.CanRequestPackageInstalls())
        return Task.FromResult(true);

    // 設定画面へ誘導(package:〜 で自アプリに直行)
    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);
}

public static partial Task LaunchInstallAsync(string apkFilePath)
{
    var context = Android.App.Application.Context;

    var apkFile = new Java.IO.File(apkFilePath);
    var uri = FileProvider.GetUriForFile(
        context,
        $"{context.PackageName}.fileprovider",
        apkFile
    );

    var intent = new Intent(Intent.ActionInstallPackage);
    intent.SetData(uri);
    intent.SetFlags(ActivityFlags.GrantReadUriPermission | ActivityFlags.NewTask);

    context.StartActivity(intent);
    return Task.CompletedTask;
}


}
#endif 

CanRequestPackageInstalls() は「このアプリがパッケージインストール要求を出せる状態か」を判定し、falseなら設定へ誘導するのが推奨です。

Intent.ActionInstallPackage は「インストーラー起動」のアクションで、入力URIは content: である必要がある旨が明記されています。またターゲットAPIが高い場合に REQUEST_INSTALL_PACKAGES が必要になる点も注意事項として示されています。

MAUI側:update.json比較→ダウンロード→インストール起動までの最小実装

以下は「更新判定(versionCode)→ダウンロード→権限確認→インストーラー起動」までの最小形です。UI(ダイアログや進捗表示)はアプリ要件に合わせて追加してください。

using System.Globalization;
using System.Security;

public sealed class UpdateInfo
{
    public long VersionCode { get; set; }
    public string? VersionName { get; set; }
    public string ApkUrl { get; set; } = "";
    public string? Sha256 { get; set; }
}

public async Task CheckAndUpdateAsync(UpdateInfo remote)
{
    // 現在のversionCode(BuildString)
    var currentVersionCode = long.Parse(AppInfo.Current.BuildString, CultureInfo.InvariantCulture);

    if (remote.VersionCode &lt;= currentVersionCode)
        return;

    // APKダウンロード先(FileProviderで共有する場所と合わせる)
    var apkPath = Path.Combine(FileSystem.CacheDirectory, "update.apk");

    using (var http = new HttpClient())
    using (var stream = await http.GetStreamAsync(remote.ApkUrl))
    using (var file = File.Create(apkPath))
    {
        await stream.CopyToAsync(file);
    }

    // 破損・改ざん検知(任意だが強く推奨)
    if (!string.IsNullOrWhiteSpace(remote.Sha256))
    {
        using var fs = File.OpenRead(apkPath);
        using var sha = System.Security.Cryptography.SHA256.Create();
        var hash = Convert.ToHexString(sha.ComputeHash(fs)).ToLowerInvariant();

        if (!string.Equals(hash, remote.Sha256.Trim().ToLowerInvariant(), StringComparison.Ordinal))
            throw new SecurityException("APKの検証に失敗しました。");
    }

    // 許可確認 → 未許可なら設定へ誘導して中断
    if (!await ApkInstaller.EnsureInstallPermissionAsync())
        return;

    // インストーラー起動
    await ApkInstaller.LaunchInstallAsync(apkPath);
}

AppInfo.Current.BuildString がAndroidの versionCode に対応し、VersionString が versionName に対応します。更新判定をBuildStringに寄せることで、OSのアップグレード判定とズレにくくなります。

「更新できない」原因はバージョン以外にもある(現場で多い落とし穴)

症状よくある原因確認ポイント対処
インストール画面は出るが更新されないversionCodeが増えていない/同じ端末のBuildStringとupdate.jsonのversionCode新しいAPKをビルドし、ApplicationVersion(整数)を増やす
「インストールできません」などで失敗する署名鍵が違う(debugとrelease混在含む)過去に入っているアプリの署名と一致しているか同一の署名鍵で署名して配布。鍵を失うと更新不能になるのでバックアップ必須
FileProvider例外(configured rootが見つからない等)file_paths.xmlと保存先の不一致CacheDirectoryに置いたのにfiles-pathを指定していないか保存先に合わせて cache-path/files-path を揃える
許可がなくてブロックされるAndroid 8+ の「不明なアプリのインストール」未許可CanRequestPackageInstalls() の戻りSettings.ActionManageUnknownAppSourcesへ誘導する導線を実装
update.jsonを更新したのに古い情報を見ているCDN/プロキシ/HTTPキャッシュレスポンスヘッダ、端末側の取得ログCache-Control調整、URLにクエリを付ける等で確実に最新を取得
更新後も表示されるバージョンが変わらない更新後にアプリを開き直していない/表示がキャッシュインストール完了後に起動し直しているかインストール完了後の再起動案内をUIで明確化

特に署名鍵は致命傷になりがちです。Androidの更新モデルでは署名鍵の継続性が前提で、鍵を失うと更新できなくなる点が公式ドキュメントでも明確に説明されています。

最小API 21 / ターゲットAPIの考え方(MAUIでどう設定するか)

minSdkVersionとtargetSdkVersionの役割

minSdkVersion は「インストール可能な下限」、targetSdkVersion は「このAPIレベル向けに動作確認した」という宣言で、互換挙動(互換モード)や制限の適用に関係します。Androidは新しいリリースに合わせてtargetを上げ、十分にテストすることを推奨しています。

.NET MAUI / .NET for Androidでは何が対応する?

.NET 8系のAndroidでは、SupportedOSPlatformVersion が minSdkVersion、TargetFramework が targetSdkVersion にマップされ、ビルド時に <uses-sdk/> が自動的に組み込まれる、と整理できます。さらに net8.0-android は net8.0-android34.0 の省略形であることも明記されています。

min=21はどう?

min=21(Android 5.0相当)にすると古い端末までカバーできますが、そのぶんテスト範囲とサポートコストは増えます。ライブラリや要件(セキュリティ、暗号、WebView、Bluetooth、バックグラウンド制限など)によっては、実務上もう少し引き上げる判断もあり得ます。まずは「本当に21が必要か」をユーザー分布と運用体制で決めるのが現実的です。

targetはどう決める?(配布形態で最適解が変わる)

配布形態targetの基本方針注意点
サーバー配布(社内配布/サイト配布)可能な限り新しいtargetでビルドして、互換挙動に頼らない不明アプリ許可・FileProvider・署名鍵の継続管理が重要
Google Play配布Google Playのtarget API要件に追従2025年8月31日以降、アプリ更新はAndroid 15(API 35)以上が必要(例外あり)

Google Play配布の場合、target API要件が明確に定義されています。2025年8月31日以降、(原則として)新規アプリとアプリ更新はAndroid 15(API 35)以上をtargetにする必要がある、と公式に案内されています。

一方でサーバー配布でも、targetを低いまま放置すると、互換モード頼みで将来のOS変更に弱くなります。MAUI/.NET側はAndroid APIのバインディングが揃えば net8.0-android35.0 のようにTFMを上げて追従できる設計であることも示されています。

Playストア配布と“自前更新”は混ぜないほうが安全

もし同じアプリをGoogle Playでも配布する可能性があるなら、「サーバー配布APKをアプリが勝手に更新する」挙動はポリシー面・ユーザー体験面で問題になりやすいです。Android公式の説明でも、Play配布アプリが他アプリのインストール/更新を行うケースは大半が不適切で、通常はPlayストアへの導線を使うべき、という注意が書かれています。

将来的には、サイドロード領域でも開発者検証(developer verification)の導入が予定されており、サーバー配布の運用は今後も要件が増える可能性があります。自前更新を採るなら、こうしたプラットフォーム側の流れも定期的にウォッチしておくのが安全です。

自動更新を“壊れにくく”するための実務ポイント

  • APKの署名鍵を絶対に失わない:更新の継続性に直結します。鍵・パスワード・保管手順をチーム運用に組み込みます。
  • update.jsonとAPKは必ずHTTPSで配布:中間者攻撃対策の基本です。
  • SHA-256等で検証:CDNや通信エラー、第三者改ざんの検知に役立ちます。
  • 段階的ロールアウト:いきなり全員に配らず、まず少数端末で検証→徐々に拡大。
  • 強制更新ラインを設ける:API互換が壊れた時に“古い版を切る”ための逃げ道(minSupportedVersionCodeなど)。
  • 再起動(開き直し)前提のUI:インストール完了=即反映ではないため、手順を短く明確に出す。

まとめ(結局なにを直せばよいか)

  • アプリのバージョン(versionCode/versionName)は実行中に書き換えられない。更新したいなら“新しいAPKをビルドしてversionCodeを増やす”。
  • .NET MAUIでは ApplicationVersion=versionCode、ApplicationDisplayVersion=versionName。更新判定はBuildString(versionCode)で行う。
  • アプリ内更新はFileProvider+権限(不明アプリ許可)+インストーラー起動のセットで成立する。
  • min=21は対応範囲、targetは基本“新しめ”へ。Play配布ならtarget要件(API 35など)に必ず追従。

この記事を書いた人

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

コメント

コメントする

目次