Эх сурвалжийг харах

feat: download and install updates in app

Replace the release-page handoff with an in-app update flow: download the universal APK to cache, validate package/version, expose it through FileProvider and open Android's package installer. Add REQUEST_INSTALL_PACKAGES, one-time unknown-apps handling, and the Update action. Rebuild the installer-enabled 1.0.1 APK; the emulator completed 1.0.0 -> 1.0.1 and the tablet has the 1.0.0 installer build.
Krasimir Kazakov 1 долоо хоног өмнө
parent
commit
eb243b119a

+ 1 - 1
AGENTS.md

@@ -97,7 +97,7 @@ notifyListeners();
 - The Music equalizer follows `AudioManager.isMusicActive`. Now-playing title/progress use the notification-listener component and active MediaSession; never read or store notification content.
 - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock.
 - GPS speed is a fallback only when no OBD adapter is connected. It starts only with already-granted fine location and GPS; never prompt from that path, and clamp stationary noise below 2 km/h to 0.
-- Updates are a non-blocking public Gogs release check. Never auto-download or silently install an APK; the Download action only opens the release page.
+- Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.
 - The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Playback uses Android media keys; title/progress need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing.

+ 13 - 9
README.md

@@ -78,8 +78,9 @@ live vehicle data.
   reads **Nothing playing**. The volume slider, −/+, and Mute pill remain.
 - **Update check** — on startup, CarDash queries the public Gogs release list
   without blocking the dashboard. Settings → Updates shows the current version,
-  a Check button, and a Download button that opens the release page. It never
-  auto-installs an APK.
+  a Check button, and an Update action that downloads the APK and opens Android’s
+  installer. Android still requires confirmation and a one-time “install unknown
+  apps” allowance; silent installation is not attempted.
 - **Now-playing access** — Settings → Media grants Android notification access,
   used only to read the active media session’s title, duration and position.
   CarDash never reads or stores notification content.
@@ -238,8 +239,10 @@ the app resumes.
 
 **Updates**
 - Non-blocking check against the public Gogs releases.
-- Shows current/latest version; Download opens the release page.
-- No automatic download or silent installation.
+- Shows current/latest version; Update downloads the APK and opens Android’s
+  installer.
+- Android confirms the install and, on first use, asks to allow CarDash as an
+  unknown-app source. No silent installation.
 
 **Security**
 - Set, change or remove a four-digit PIN.
@@ -300,8 +303,9 @@ title, artist, progress and pause/resume were verified against a live Chrome
 MediaSession. The OBD hardware path compiles and is covered by the parser/economy
 design, but no ELM327 adapter is available on this machine; socket behavior still
 needs a car test. Release
-The 1.0.1 universal APK is built from this tree. The emulator and physical
-Android 17 tablet have the 1.0.0 updater build installed for testing the update flow.
+The 1.0.1 universal APK is built from this tree. The emulator completed a live
+1.0.0 → 1.0.1 in-app update; the physical tablet has the 1.0.0 installer build
+ready for its own test.
 
 ## Troubleshooting the emulator
 
@@ -354,9 +358,9 @@ Android 15 AVD is left running headlessly after development; use
 - **Play/pause uses Android media keys.** It resumes a paused session when one
   responds; if nothing is playing, it opens the app assigned to the Music slot
   (or asks you to choose one). The ring shows Play whenever audio is inactive.
-- **Updates are notification-only.** Settings → Updates checks the public Gogs
-  release list and opens the release page; CarDash never downloads or installs an
-  APK automatically.
+- **Updates download and invoke Android’s installer.** CarDash never silently
+  installs. Android requires the user to confirm each install and, once, to allow
+  CarDash as an unknown-app source.
 - **Song progress/title require notification access.** Without it, Android does
   not expose MediaSession metadata to a normal launcher; the card still controls
   playback and shows Nothing playing or Playing.

+ 11 - 0
android/app/src/main/AndroidManifest.xml

@@ -10,6 +10,7 @@
     <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
     <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
     <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+    <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
 
     <uses-feature android:name="android.hardware.bluetooth" android:required="false" />
     <uses-feature android:name="android.hardware.location.gps" android:required="false" />
@@ -52,6 +53,16 @@
             </intent-filter>
         </activity>
 
+        <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/file_paths" />
+        </provider>
+
         <service
             android:name=".CardashNotificationListener"
             android:label="CarDash now playing"

+ 141 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/LauncherBridge.kt

@@ -10,6 +10,7 @@ import android.graphics.Bitmap
 import android.graphics.Canvas
 import android.graphics.drawable.Drawable
 import android.net.Uri
+import androidx.core.content.FileProvider
 import android.os.Handler
 import android.os.Looper
 import android.provider.Settings
@@ -17,6 +18,9 @@ import io.flutter.plugin.common.BinaryMessenger
 import io.flutter.plugin.common.MethodCall
 import io.flutter.plugin.common.MethodChannel
 import java.io.ByteArrayOutputStream
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
 import java.util.concurrent.Executors
 
 /**
@@ -34,7 +38,9 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
 
     private var channel: MethodChannel? = null
     private val executor = Executors.newSingleThreadExecutor()
+    private val updateExecutor = Executors.newSingleThreadExecutor()
     private val mainHandler = Handler(Looper.getMainLooper())
+    @Volatile private var updateBusy = false
     @Volatile private var disposed = false
 
     fun attach(messenger: BinaryMessenger) {
@@ -46,6 +52,7 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
         channel?.setMethodCallHandler(null)
         channel = null
         executor.shutdownNow()
+        updateExecutor.shutdownNow()
     }
 
     override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@@ -55,10 +62,144 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
             "isDefaultHome" -> result.success(isDefaultHome())
             "requestDefaultHome" -> result.success(requestDefaultHome())
             "openUrl" -> openUrl(call, result)
+            "downloadAndInstallUpdate" -> downloadAndInstallUpdate(call, result)
             else -> result.notImplemented()
         }
     }
 
+    private fun downloadAndInstallUpdate(call: MethodCall, result: MethodChannel.Result) {
+        val url = call.argument<String>("url")
+        if (url.isNullOrBlank()) {
+            result.error("bad_arguments", "An update URL is required.", null)
+            return
+        }
+        if (!canInstallPackages()) {
+            openUnknownSourcesSettings()
+            result.success(
+                mapOf(
+                    "started" to false,
+                    "permissionRequired" to true,
+                    "message" to "Allow CarDash to install unknown apps, then tap Update again.",
+                ),
+            )
+            return
+        }
+        if (updateBusy) {
+            result.error("busy", "An update download is already running.", null)
+            return
+        }
+        updateBusy = true
+        updateExecutor.execute {
+            try {
+                val uri = Uri.parse(url)
+                if (uri.scheme != "https" || uri.host != "git.krasi.net") {
+                    throw IllegalArgumentException("Untrusted update URL.")
+                }
+                val directory = File(activity.cacheDir, "updates")
+                if (!directory.exists() && !directory.mkdirs()) {
+                    throw IllegalStateException("Could not prepare update storage.")
+                }
+                val target = File(directory, "cardash-update.apk")
+                val partial = File(directory, "cardash-update.apk.part")
+                if (target.exists()) target.delete()
+                if (partial.exists()) partial.delete()
+
+                val connection = (URL(url).openConnection() as HttpURLConnection).apply {
+                    instanceFollowRedirects = true
+                    connectTimeout = 15_000
+                    readTimeout = 90_000
+                    setRequestProperty("User-Agent", "CarDash/${activity.packageName}")
+                }
+                try {
+                    val status = connection.responseCode
+                    if (status !in 200..299) {
+                        throw IllegalStateException("Update download failed (HTTP $status).")
+                    }
+                    connection.inputStream.use { input ->
+                        partial.outputStream().buffered().use { output ->
+                            input.copyTo(output)
+                        }
+                    }
+                } finally {
+                    connection.disconnect()
+                }
+                if (partial.length() <= 0L) {
+                    throw IllegalStateException("Downloaded update is empty.")
+                }
+                if (!partial.renameTo(target)) {
+                    throw IllegalStateException("Could not finalize the update file.")
+                }
+
+                val archive = activity.packageManager.getPackageArchiveInfo(target.absolutePath, 0)
+                @Suppress("DEPRECATION")
+                val current = activity.packageManager.getPackageInfo(activity.packageName, 0)
+                if (archive == null || archive.packageName != activity.packageName) {
+                    target.delete()
+                    throw IllegalStateException("Downloaded file is not a CarDash package.")
+                }
+                if (archive.longVersionCode <= current.longVersionCode) {
+                    target.delete()
+                    throw IllegalStateException("Downloaded update is not newer than the installed app.")
+                }
+
+                val authority = activity.packageName + ".fileprovider"
+                val contentUri = FileProvider.getUriForFile(activity, authority, target)
+                val intent = Intent(Intent.ACTION_VIEW).apply {
+                    setDataAndType(contentUri, "application/vnd.android.package-archive")
+                    addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+                    addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+                }
+                activity.runOnUiThread {
+                    try {
+                        activity.startActivity(intent)
+                        result.success(
+                            mapOf(
+                                "started" to true,
+                                "permissionRequired" to false,
+                                "message" to "Download complete. Confirm installation in the system prompt.",
+                            ),
+                        )
+                    } catch (_: Throwable) {
+                        result.success(
+                            mapOf(
+                                "started" to false,
+                                "permissionRequired" to false,
+                                "message" to "Could not open the Android installer.",
+                            ),
+                        )
+                    }
+                }
+            } catch (problem: Throwable) {
+                result.success(
+                    mapOf(
+                        "started" to false,
+                        "permissionRequired" to false,
+                        "message" to (problem.message ?: "Could not download the update."),
+                    ),
+                )
+            } finally {
+                updateBusy = false
+            }
+        }
+    }
+
+    private fun canInstallPackages(): Boolean {
+        return activity.packageManager.canRequestPackageInstalls()
+    }
+
+    private fun openUnknownSourcesSettings() {
+        try {
+            activity.startActivity(
+                Intent(
+                    Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+                    Uri.parse("package:${activity.packageName}"),
+                ),
+            )
+        } catch (_: Throwable) {
+            // The caller still receives permissionRequired and can retry.
+        }
+    }
+
     private fun openUrl(call: MethodCall, result: MethodChannel.Result) {
         val url = call.argument<String>("url")
         if (url.isNullOrBlank()) {

+ 4 - 0
android/app/src/main/res/xml/file_paths.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+    <cache-path name="updates" path="updates/" />
+</paths>

+ 5 - 2
docs/IMPLEMENTATION_PLAN.md

@@ -77,7 +77,7 @@ compatibility:
 | Economy | 5 km MAF rolling window, petrol constants, fuel-cut guard | Matches the design note; show `—` until 0.5 km. |
 | Weather fallback | Open-Meteo + one-shot device location, Settings toggle | No API key/account; hide the row on any failure. |
 | GPS speed fallback | Poll GPS updates only when no OBD is connected and fine location is already granted | No permission prompt; 0 below 2 km/h to suppress stationary drift. |
-| Update check | Public Gogs releases API, non-blocking startup check, Settings card | Notification only; Download opens release page and installation stays manual. |
+| Update check | Public Gogs releases API, non-blocking startup check, Settings card | Update downloads the APK and opens Android’s installer; installation stays user-confirmed. |
 | Temperature priority | OBD ambient, else location weather | OBD wins whenever a fresh value exists. |
 | Accent background | Dark canvas/surfaces/borders blend toward the accent | Fixes the green base showing through when Blue is selected. |
 | Screen | `FLAG_KEEP_SCREEN_ON` controlled by the Settings keep-awake switch | Default on; no wake-lock permission, and off restores the system display timeout. |
@@ -197,6 +197,8 @@ Android (Kotlin)
 - [x] GPS speed shows 0 km/h when stationary with no OBD adapter; no permission prompt.
 - [x] 1.0.0 updater build installed on emulator/tablet; it reported up to date before 1.0.1.
 - [x] 1.0.1 universal APK built and added to `releases/`; release published for update testing.
+- [x] Emulator completed the full in-app 1.0.0 → 1.0.1 download/installer flow.
+- [x] Tablet has the 1.0.0 installer build; unknown-app confirmation is user-controlled.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
 ## 6. Known risks and mitigations
@@ -290,7 +292,8 @@ Android (Kotlin)
   2 km/h render as 0. Verified 47 tests and 0.10.0 on emulator/tablet.
 - 2026-10-30: Added a non-blocking Gogs update checker and installed a 1.0.0
   updater build on emulator/tablet. Published 1.0.1+13 as a version-only update
-  test; 50 tests passed.
+  test; 50 tests passed. The emulator later completed the in-app 1.0.0 → 1.0.1
+  download and package-installer flow.
 - 2026-10-30: Versioned the universal Android release as 1.0.0+12 and added
   `releases/cardash-android-1.0.0.apk` with checksum and release notes.
 - 2026-10-29: Centered the now-playing title across the whole media card instead of

+ 20 - 0
lib/data/models/update_info.dart

@@ -2,6 +2,26 @@ import 'package:flutter/foundation.dart';
 
 /// The latest published Gogs release relevant to the update check.
 @immutable
+class UpdateInstallResult {
+  const UpdateInstallResult({
+    required this.started,
+    required this.permissionRequired,
+    this.message,
+  });
+
+  final bool started;
+  final bool permissionRequired;
+  final String? message;
+
+  factory UpdateInstallResult.fromMap(Map<dynamic, dynamic> map) {
+    return UpdateInstallResult(
+      started: map['started'] == true,
+      permissionRequired: map['permissionRequired'] == true,
+      message: map['message'] as String?,
+    );
+  }
+}
+
 class UpdateInfo {
   const UpdateInfo({
     required this.currentVersion,

+ 31 - 0
lib/data/services/native_bridge.dart

@@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
 
 import '../models/app_entry.dart';
 import '../models/media_playback.dart';
+import '../models/update_info.dart';
 import '../models/system_status.dart';
 
 class LauncherPlatformException implements Exception {
@@ -45,6 +46,8 @@ abstract class LauncherPlatform {
   Future<void> setKeepScreenOn(bool enabled);
 
   Future<bool> openUrl(String url);
+
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url);
 }
 
 class NativeBridge implements LauncherPlatform {
@@ -195,6 +198,34 @@ class NativeBridge implements LauncherPlatform {
     }
   }
 
+  @override
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
+    try {
+      final raw = await _appsChannel.invokeMethod<dynamic>(
+        'downloadAndInstallUpdate',
+        {'url': url},
+      );
+      if (raw is Map) return UpdateInstallResult.fromMap(raw);
+      return const UpdateInstallResult(
+        started: false,
+        permissionRequired: false,
+        message: 'The update installer did not start.',
+      );
+    } on PlatformException catch (error) {
+      return UpdateInstallResult(
+        started: false,
+        permissionRequired: false,
+        message: error.message ?? 'Could not download the update.',
+      );
+    } on MissingPluginException {
+      return const UpdateInstallResult(
+        started: false,
+        permissionRequired: false,
+        message: 'The Android update bridge is unavailable.',
+      );
+    }
+  }
+
   @override
   Future<bool> openUrl(String url) async {
     try {

+ 6 - 18
lib/features/settings/settings_screen.dart

@@ -721,14 +721,16 @@ class _UpdateRow extends StatelessWidget {
     final colors = context.colors;
     final theme = Theme.of(context);
     final available = update.updateAvailable;
-    final status = update.checking
+    final status = update.installing
+        ? 'Downloading update…'
+        : update.checking
         ? 'Checking for updates…'
         : available
         ? 'Version ${update.latestVersion} is available.'
         : update.latestVersion != null
         ? 'CarDash ${update.currentVersion} is up to date.'
         : 'Current version ${update.currentVersion}.';
-    final detail = update.error;
+    final detail = update.installMessage ?? update.error;
 
     return Row(
       children: [
@@ -773,7 +775,7 @@ class _UpdateRow extends StatelessWidget {
           ),
         ),
         const SizedBox(width: 10),
-        if (update.checking)
+        if (update.checking || update.installing)
           SizedBox(
             width: 20,
             height: 20,
@@ -783,21 +785,7 @@ class _UpdateRow extends StatelessWidget {
             ),
           )
         else if (available)
-          _SmallButton(
-            label: 'Download',
-            onTap: () async {
-              final opened = await update.openReleasePage();
-              if (!opened && context.mounted) {
-                final messenger = ScaffoldMessenger.of(context);
-                messenger.hideCurrentSnackBar();
-                messenger.showSnackBar(
-                  const SnackBar(
-                    content: Text('Could not open the release page.'),
-                  ),
-                );
-              }
-            },
-          )
+          _SmallButton(label: 'Update', onTap: update.installUpdate)
         else
           _SmallButton(label: 'Check', onTap: update.check),
       ],

+ 30 - 0
lib/features/settings/update_controller.dart

@@ -17,9 +17,11 @@ class UpdateController extends ChangeNotifier {
 
   UpdateInfo? _result;
   bool _checking = false;
+  bool _installing = false;
   bool _started = false;
   bool _disposed = false;
   String? _error;
+  String? _installMessage;
 
   String get currentVersion => appVersion;
 
@@ -29,6 +31,10 @@ class UpdateController extends ChangeNotifier {
 
   bool get updateAvailable => _result?.available ?? false;
 
+  bool get installing => _installing;
+
+  String? get installMessage => _installMessage;
+
   String? get error => _error;
 
   String? get releasePageUrl => _result?.releasePageUrl;
@@ -43,6 +49,7 @@ class UpdateController extends ChangeNotifier {
     if (_checking) return;
     _checking = true;
     _error = null;
+    _installMessage = null;
     _notify();
     try {
       _result = await _service.check(appVersion);
@@ -56,6 +63,29 @@ class UpdateController extends ChangeNotifier {
     }
   }
 
+  Future<void> installUpdate() async {
+    final result = _result;
+    if (_installing || result == null || !result.available) return;
+    _installing = true;
+    _installMessage = 'Downloading update…';
+    _error = null;
+    _notify();
+    try {
+      final installed = await _platform.downloadAndInstallUpdate(
+        result.downloadUrl,
+      );
+      _installMessage = installed.message;
+      if (!installed.started && !installed.permissionRequired) {
+        _error = installed.message ?? 'Could not start the update installer.';
+      }
+    } catch (_) {
+      _error = 'Could not download the update.';
+    } finally {
+      _installing = false;
+      _notify();
+    }
+  }
+
   Future<bool> openReleasePage() {
     return _platform.openUrl(
       _result?.releasePageUrl ?? UpdateService.releasePageUrl,

+ 3 - 1
releases/README.md

@@ -5,8 +5,10 @@
 - **File:** `cardash-android-1.0.1.apk`
 - **Version:** 1.0.1 (versionCode 13)
 - **ABIs:** arm64-v8a, armeabi-v7a, x86_64
-- **SHA-256:** `fd743d120297a95979eb3117d8d19d0cb95c3dfd96db9405a2f8a2821bf79b0c`
+- **SHA-256:** `e7fee1eecb428d05003d01688ad561c614a392012629f96e701eb36ce972f6a7`
 - **Changes:** version bump only; no functional changes from the 1.0.0 updater build.
+- The in-app Updates action downloads this APK and opens Android’s installer; the
+  user still confirms the installation and allows unknown-app installs once.
 
 ## 1.0.0 — first universal release
 

BIN
releases/cardash-android-1.0.1.apk


+ 1 - 1
releases/cardash-android-1.0.1.apk.sha256

@@ -1 +1 @@
-fd743d120297a95979eb3117d8d19d0cb95c3dfd96db9405a2f8a2821bf79b0c  releases/cardash-android-1.0.1.apk
+e7fee1eecb428d05003d01688ad561c614a392012629f96e701eb36ce972f6a7  releases/cardash-android-1.0.1.apk

+ 10 - 0
test/display_controller_test.dart

@@ -1,6 +1,7 @@
 import 'package:cardash_launcher/core/display_controller.dart';
 import 'package:cardash_launcher/data/models/app_entry.dart';
 import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:cardash_launcher/data/models/update_info.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
@@ -53,6 +54,15 @@ class _DisplayPlatform implements LauncherPlatform {
 
   @override
   Future<bool> openUrl(String url) async => true;
+
+  @override
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
+    return const UpdateInstallResult(
+      started: false,
+      permissionRequired: false,
+      message: 'not started',
+    );
+  }
 }
 
 void main() {

+ 10 - 0
test/launcher_controller_test.dart

@@ -1,5 +1,6 @@
 import 'package:cardash_launcher/data/models/app_entry.dart';
 import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:cardash_launcher/data/models/update_info.dart';
 import 'package:cardash_launcher/data/models/slot_type.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
@@ -54,6 +55,15 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
   @override
   Future<bool> openUrl(String url) async => true;
+
+  @override
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
+    return const UpdateInstallResult(
+      started: false,
+      permissionRequired: false,
+      message: 'not started',
+    );
+  }
 }
 
 List<AppEntry> _apps(int count) {

+ 10 - 0
test/system_controller_test.dart

@@ -1,5 +1,6 @@
 import 'package:cardash_launcher/data/models/app_entry.dart';
 import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:cardash_launcher/data/models/update_info.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/features/home/system_controller.dart';
@@ -56,6 +57,15 @@ class _MediaPlatform implements LauncherPlatform {
 
   @override
   Future<bool> openUrl(String url) async => true;
+
+  @override
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
+    return const UpdateInstallResult(
+      started: false,
+      permissionRequired: false,
+      message: 'not started',
+    );
+  }
 }
 
 void main() {

+ 15 - 0
test/update_controller_test.dart

@@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart';
 
 class _UpdatePlatform implements LauncherPlatform {
   final List<String> opened = [];
+  final List<String> installed = [];
 
   @override
   Future<bool> openUrl(String url) async {
@@ -16,6 +17,16 @@ class _UpdatePlatform implements LauncherPlatform {
     return true;
   }
 
+  @override
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
+    installed.add(url);
+    return const UpdateInstallResult(
+      started: true,
+      permissionRequired: false,
+      message: 'Installer opened.',
+    );
+  }
+
   @override
   Future<List<AppEntry>> listApps() async => const [];
 
@@ -91,6 +102,10 @@ void main() {
     expect(controller.latestVersion, '1.0.1');
     expect(await controller.openReleasePage(), isTrue);
     expect(platform.opened.single, 'https://example.test/releases');
+
+    await controller.installUpdate();
+    expect(platform.installed.single, 'https://example.test/app.apk');
+    expect(controller.installMessage, 'Installer opened.');
   });
 
   test('reports up to date when no newer release exists', () async {