Просмотр исходного кода

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 неделя назад
Родитель
Сommit
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.
 - 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.
 - 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.
 - 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.
 - 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.
 - 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.
 - 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.
   reads **Nothing playing**. The volume slider, −/+, and Mute pill remain.
 - **Update check** — on startup, CarDash queries the public Gogs release list
 - **Update check** — on startup, CarDash queries the public Gogs release list
   without blocking the dashboard. Settings → Updates shows the current version,
   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,
 - **Now-playing access** — Settings → Media grants Android notification access,
   used only to read the active media session’s title, duration and position.
   used only to read the active media session’s title, duration and position.
   CarDash never reads or stores notification content.
   CarDash never reads or stores notification content.
@@ -238,8 +239,10 @@ the app resumes.
 
 
 **Updates**
 **Updates**
 - Non-blocking check against the public Gogs releases.
 - 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**
 **Security**
 - Set, change or remove a four-digit PIN.
 - 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
 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
 design, but no ELM327 adapter is available on this machine; socket behavior still
 needs a car test. Release
 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
 ## 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
 - **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
   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.
   (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
 - **Song progress/title require notification access.** Without it, Android does
   not expose MediaSession metadata to a normal launcher; the card still controls
   not expose MediaSession metadata to a normal launcher; the card still controls
   playback and shows Nothing playing or Playing.
   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" android:maxSdkVersion="30" />
     <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" 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.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.bluetooth" android:required="false" />
     <uses-feature android:name="android.hardware.location.gps" android:required="false" />
     <uses-feature android:name="android.hardware.location.gps" android:required="false" />
@@ -52,6 +53,16 @@
             </intent-filter>
             </intent-filter>
         </activity>
         </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
         <service
             android:name=".CardashNotificationListener"
             android:name=".CardashNotificationListener"
             android:label="CarDash now playing"
             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.Canvas
 import android.graphics.drawable.Drawable
 import android.graphics.drawable.Drawable
 import android.net.Uri
 import android.net.Uri
+import androidx.core.content.FileProvider
 import android.os.Handler
 import android.os.Handler
 import android.os.Looper
 import android.os.Looper
 import android.provider.Settings
 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.MethodCall
 import io.flutter.plugin.common.MethodChannel
 import io.flutter.plugin.common.MethodChannel
 import java.io.ByteArrayOutputStream
 import java.io.ByteArrayOutputStream
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
 import java.util.concurrent.Executors
 import java.util.concurrent.Executors
 
 
 /**
 /**
@@ -34,7 +38,9 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
 
 
     private var channel: MethodChannel? = null
     private var channel: MethodChannel? = null
     private val executor = Executors.newSingleThreadExecutor()
     private val executor = Executors.newSingleThreadExecutor()
+    private val updateExecutor = Executors.newSingleThreadExecutor()
     private val mainHandler = Handler(Looper.getMainLooper())
     private val mainHandler = Handler(Looper.getMainLooper())
+    @Volatile private var updateBusy = false
     @Volatile private var disposed = false
     @Volatile private var disposed = false
 
 
     fun attach(messenger: BinaryMessenger) {
     fun attach(messenger: BinaryMessenger) {
@@ -46,6 +52,7 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
         channel?.setMethodCallHandler(null)
         channel?.setMethodCallHandler(null)
         channel = null
         channel = null
         executor.shutdownNow()
         executor.shutdownNow()
+        updateExecutor.shutdownNow()
     }
     }
 
 
     override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
     override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
@@ -55,10 +62,144 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
             "isDefaultHome" -> result.success(isDefaultHome())
             "isDefaultHome" -> result.success(isDefaultHome())
             "requestDefaultHome" -> result.success(requestDefaultHome())
             "requestDefaultHome" -> result.success(requestDefaultHome())
             "openUrl" -> openUrl(call, result)
             "openUrl" -> openUrl(call, result)
+            "downloadAndInstallUpdate" -> downloadAndInstallUpdate(call, result)
             else -> result.notImplemented()
             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) {
     private fun openUrl(call: MethodCall, result: MethodChannel.Result) {
         val url = call.argument<String>("url")
         val url = call.argument<String>("url")
         if (url.isNullOrBlank()) {
         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. |
 | 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. |
 | 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. |
 | 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. |
 | 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. |
 | 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. |
 | 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] 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.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] 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.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
 
 ## 6. Known risks and mitigations
 ## 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.
   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
 - 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
   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
 - 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.
   `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
 - 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.
 /// The latest published Gogs release relevant to the update check.
 @immutable
 @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 {
 class UpdateInfo {
   const UpdateInfo({
   const UpdateInfo({
     required this.currentVersion,
     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/app_entry.dart';
 import '../models/media_playback.dart';
 import '../models/media_playback.dart';
+import '../models/update_info.dart';
 import '../models/system_status.dart';
 import '../models/system_status.dart';
 
 
 class LauncherPlatformException implements Exception {
 class LauncherPlatformException implements Exception {
@@ -45,6 +46,8 @@ abstract class LauncherPlatform {
   Future<void> setKeepScreenOn(bool enabled);
   Future<void> setKeepScreenOn(bool enabled);
 
 
   Future<bool> openUrl(String url);
   Future<bool> openUrl(String url);
+
+  Future<UpdateInstallResult> downloadAndInstallUpdate(String url);
 }
 }
 
 
 class NativeBridge implements LauncherPlatform {
 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
   @override
   Future<bool> openUrl(String url) async {
   Future<bool> openUrl(String url) async {
     try {
     try {

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

@@ -721,14 +721,16 @@ class _UpdateRow extends StatelessWidget {
     final colors = context.colors;
     final colors = context.colors;
     final theme = Theme.of(context);
     final theme = Theme.of(context);
     final available = update.updateAvailable;
     final available = update.updateAvailable;
-    final status = update.checking
+    final status = update.installing
+        ? 'Downloading update…'
+        : update.checking
         ? 'Checking for updates…'
         ? 'Checking for updates…'
         : available
         : available
         ? 'Version ${update.latestVersion} is available.'
         ? 'Version ${update.latestVersion} is available.'
         : update.latestVersion != null
         : update.latestVersion != null
         ? 'CarDash ${update.currentVersion} is up to date.'
         ? 'CarDash ${update.currentVersion} is up to date.'
         : 'Current version ${update.currentVersion}.';
         : 'Current version ${update.currentVersion}.';
-    final detail = update.error;
+    final detail = update.installMessage ?? update.error;
 
 
     return Row(
     return Row(
       children: [
       children: [
@@ -773,7 +775,7 @@ class _UpdateRow extends StatelessWidget {
           ),
           ),
         ),
         ),
         const SizedBox(width: 10),
         const SizedBox(width: 10),
-        if (update.checking)
+        if (update.checking || update.installing)
           SizedBox(
           SizedBox(
             width: 20,
             width: 20,
             height: 20,
             height: 20,
@@ -783,21 +785,7 @@ class _UpdateRow extends StatelessWidget {
             ),
             ),
           )
           )
         else if (available)
         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
         else
           _SmallButton(label: 'Check', onTap: update.check),
           _SmallButton(label: 'Check', onTap: update.check),
       ],
       ],

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

@@ -17,9 +17,11 @@ class UpdateController extends ChangeNotifier {
 
 
   UpdateInfo? _result;
   UpdateInfo? _result;
   bool _checking = false;
   bool _checking = false;
+  bool _installing = false;
   bool _started = false;
   bool _started = false;
   bool _disposed = false;
   bool _disposed = false;
   String? _error;
   String? _error;
+  String? _installMessage;
 
 
   String get currentVersion => appVersion;
   String get currentVersion => appVersion;
 
 
@@ -29,6 +31,10 @@ class UpdateController extends ChangeNotifier {
 
 
   bool get updateAvailable => _result?.available ?? false;
   bool get updateAvailable => _result?.available ?? false;
 
 
+  bool get installing => _installing;
+
+  String? get installMessage => _installMessage;
+
   String? get error => _error;
   String? get error => _error;
 
 
   String? get releasePageUrl => _result?.releasePageUrl;
   String? get releasePageUrl => _result?.releasePageUrl;
@@ -43,6 +49,7 @@ class UpdateController extends ChangeNotifier {
     if (_checking) return;
     if (_checking) return;
     _checking = true;
     _checking = true;
     _error = null;
     _error = null;
+    _installMessage = null;
     _notify();
     _notify();
     try {
     try {
       _result = await _service.check(appVersion);
       _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() {
   Future<bool> openReleasePage() {
     return _platform.openUrl(
     return _platform.openUrl(
       _result?.releasePageUrl ?? UpdateService.releasePageUrl,
       _result?.releasePageUrl ?? UpdateService.releasePageUrl,

+ 3 - 1
releases/README.md

@@ -5,8 +5,10 @@
 - **File:** `cardash-android-1.0.1.apk`
 - **File:** `cardash-android-1.0.1.apk`
 - **Version:** 1.0.1 (versionCode 13)
 - **Version:** 1.0.1 (versionCode 13)
 - **ABIs:** arm64-v8a, armeabi-v7a, x86_64
 - **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.
 - **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
 ## 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/core/display_controller.dart';
 import 'package:cardash_launcher/data/models/app_entry.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/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/models/system_status.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
@@ -53,6 +54,15 @@ class _DisplayPlatform implements LauncherPlatform {
 
 
   @override
   @override
   Future<bool> openUrl(String url) async => true;
   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() {
 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/app_entry.dart';
 import 'package:cardash_launcher/data/models/media_playback.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/slot_type.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
@@ -54,6 +55,15 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
 
   @override
   @override
   Future<bool> openUrl(String url) async => true;
   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) {
 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/app_entry.dart';
 import 'package:cardash_launcher/data/models/media_playback.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/models/system_status.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/features/home/system_controller.dart';
 import 'package:cardash_launcher/features/home/system_controller.dart';
@@ -56,6 +57,15 @@ class _MediaPlatform implements LauncherPlatform {
 
 
   @override
   @override
   Future<bool> openUrl(String url) async => true;
   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() {
 void main() {

+ 15 - 0
test/update_controller_test.dart

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