Przeglądaj źródła

feat: add Gogs update check and 1.0.1 update test

Add a non-blocking Gogs release check with a Settings Updates card, current version, Check and Download actions. No auto-download or silent install. Install the 1.0.0 updater on emulator/tablet, then publish 1.0.1 with only the version changed.
Krasimir Kazakov 1 tydzień temu
rodzic
commit
0bfd245a59

+ 3 - 2
AGENTS.md

@@ -35,11 +35,11 @@ ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-
 
 
 - `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController`, `DisplayController` (keep-awake), `AppClock`, reusable widgets.
 - `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController`, `DisplayController` (keep-awake), `AppClock`, reusable widgets.
 - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
 - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
-- `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `PinService`, secure-store adapter.
+- `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, secure-store adapter.
 - `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags and pinned apps.
 - `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags and pinned apps.
 - `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, equalizer, night overlay.
 - `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, equalizer, night overlay.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
-- `lib/features/settings/` — General, Media, Car data and Security pages.
+- `lib/features/settings/` — General, Media, Car data, Security and Updates pages.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
 - `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`; `SystemBridge` also dispatches media keys and applies the keep-awake flag.
 - `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`; `SystemBridge` also dispatches media keys and applies the keep-awake flag.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
@@ -97,6 +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.
 - 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.

+ 22 - 7
README.md

@@ -17,8 +17,8 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 
 
 ## Download
 ## Download
 
 
-- [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published 1.0.0 release
-- [cardash-android-1.0.0.apk](releases/cardash-android-1.0.0.apk) — universal Android release
+- [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
+- [cardash-android-1.0.1.apk](releases/cardash-android-1.0.1.apk) — universal Android release
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 
 
 Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
 Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
@@ -76,6 +76,10 @@ live vehicle data.
   play/pause button. The song title and artist scroll along the bottom; paused
   play/pause button. The song title and artist scroll along the bottom; paused
   audio stops the scroll and holds its position. When nothing is playing it
   audio stops the scroll and holds its position. When nothing is playing it
   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
+  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.
 - **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.
@@ -232,6 +236,11 @@ the app resumes.
   OBD-II connected.” Enabling it asks for location permission.
   OBD-II connected.” Enabling it asks for location permission.
 - Read-only note: CarDash never writes to the ECU.
 - Read-only note: CarDash never writes to the ECU.
 
 
+**Updates**
+- Non-blocking check against the public Gogs releases.
+- Shows current/latest version; Download opens the release page.
+- No automatic download or silent installation.
+
 **Security**
 **Security**
 - Set, change or remove a four-digit PIN.
 - Set, change or remove a four-digit PIN.
 - The PIN is requested only on a cold start. It does not re-lock when switching
 - The PIN is requested only on a cold start. It does not re-lock when switching
@@ -277,12 +286,13 @@ flutter test
 ./tool/check.sh
 ./tool/check.sh
 ```
 ```
 
 
-The 47 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 50 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, slot visibility,
 dark accent tinting, battery visibility, first-run state, slot visibility,
 pinned apps, custom labels/icons, the 16-button cap at 1280×800, 1280×720 and
 pinned apps, custom labels/icons, the 16-button cap at 1280×800, 1280×720 and
 1024×600, the dense shortcut grid, the equalizer animation, OBD economy, the OBD
 1024×600, the dense shortcut grid, the equalizer animation, OBD economy, the OBD
 card, media-key playback, media metadata/progress, the marquee, the play/pause
 card, media-key playback, media metadata/progress, the marquee, the play/pause
-ring, the keep-awake preference, the night overlay and GPS speed fallback. Native package
+ring, the keep-awake preference, the night overlay, GPS speed fallback and the
+update version/availability logic. Native package
 enumeration, the HOME role, volume, app launching and PIN flows were verified on
 enumeration, the HOME role, volume, app launching and PIN flows were verified on
 API 35 and API 29 emulators. Location permission, reverse geocoding and the
 API 35 and API 29 emulators. Location permission, reverse geocoding and the
 Open-Meteo fallback were verified live on the API 35 emulator. Now-playing
 Open-Meteo fallback were verified live on the API 35 emulator. Now-playing
@@ -290,7 +300,8 @@ 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.0 universal APK is built from this tree. The emulator and physical Android 17 tablet last ran 0.10.0.
+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.
 
 
 ## Troubleshooting the emulator
 ## Troubleshooting the emulator
 
 
@@ -343,6 +354,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.
 - **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.
@@ -360,8 +374,9 @@ Android 15 AVD is left running headlessly after development; use
 - **`QUERY_ALL_PACKAGES`** is used to list all launchable apps. It is reasonable
 - **`QUERY_ALL_PACKAGES`** is used to list all launchable apps. It is reasonable
   for a launcher but requires a Play policy declaration if the app is ever
   for a launcher but requires a Play policy declaration if the app is ever
   distributed; this is a local/sideload build for now.
   distributed; this is a local/sideload build for now.
-- **Network access** is only for the optional Open-Meteo temperature fallback;
-  there are no accounts, ads, telemetry or update checks.
+- **Network access** is used for the optional Open-Meteo temperature fallback and
+  the Gogs release check. There are no accounts, ads, telemetry or silent
+  auto-updates.
 - The launcher cannot replace Android's lock screen. The PIN does not protect a
 - The launcher cannot replace Android's lock screen. The PIN does not protect a
   running session, by design.
   running session, by design.
 
 

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

@@ -9,6 +9,7 @@ import android.content.pm.PackageManager
 import android.graphics.Bitmap
 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.os.Handler
 import android.os.Handler
 import android.os.Looper
 import android.os.Looper
 import android.provider.Settings
 import android.provider.Settings
@@ -53,10 +54,27 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
             "launchApp" -> launchApp(call, result)
             "launchApp" -> launchApp(call, result)
             "isDefaultHome" -> result.success(isDefaultHome())
             "isDefaultHome" -> result.success(isDefaultHome())
             "requestDefaultHome" -> result.success(requestDefaultHome())
             "requestDefaultHome" -> result.success(requestDefaultHome())
+            "openUrl" -> openUrl(call, result)
             else -> result.notImplemented()
             else -> result.notImplemented()
         }
         }
     }
     }
 
 
+    private fun openUrl(call: MethodCall, result: MethodChannel.Result) {
+        val url = call.argument<String>("url")
+        if (url.isNullOrBlank()) {
+            result.error("bad_arguments", "A URL is required.", null)
+            return
+        }
+        try {
+            val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
+            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            activity.startActivity(intent)
+            result.success(true)
+        } catch (_: Throwable) {
+            result.success(false)
+        }
+    }
+
     private fun loadApps(result: MethodChannel.Result) {
     private fun loadApps(result: MethodChannel.Result) {
         executor.execute {
         executor.execute {
             val apps = try {
             val apps = try {

+ 10 - 1
docs/IMPLEMENTATION_PLAN.md

@@ -40,6 +40,8 @@ compatibility:
   3°C; no row when unavailable.
   3°C; no row when unavailable.
 - GPS speed fallback when no OBD adapter is connected and precise location was
 - GPS speed fallback when no OBD adapter is connected and precise location was
   already granted; stationary shows 0 km/h, with no permission prompt.
   already granted; stationary shows 0 km/h, with no permission prompt.
+- Non-blocking Gogs update check with a Settings → Updates card and a release-page
+  link; no automatic download or silent installation.
 - Dark surfaces are tinted by the selected accent instead of retaining a green base.
 - Dark surfaces are tinted by the selected accent instead of retaining a green base.
 - PIN is requested **only on a cold start**, never on resume or screen-off.
 - PIN is requested **only on a cold start**, never on resume or screen-off.
 - The screen stays awake while CarDash is the foreground window.
 - The screen stays awake while CarDash is the foreground window.
@@ -75,6 +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. |
 | 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. |
@@ -98,6 +101,7 @@ Flutter
     SystemController    -> battery/Wi-Fi/volume/media state + media keys
     SystemController    -> battery/Wi-Fi/volume/media state + media keys
     DisplayController   -> persisted keep-awake window flag
     DisplayController   -> persisted keep-awake window flag
     GpsSpeedController  -> GPS speed fallback (permission already granted)
     GpsSpeedController  -> GPS speed fallback (permission already granted)
+    UpdateController    -> public Gogs release check + release-page link
     HomeScreen          -> NightModeOverlay on empty-background long press
     HomeScreen          -> NightModeOverlay on empty-background long press
   AllAppsScreen / AppPickerSheet
   AllAppsScreen / AppPickerSheet
   SettingsScreen -> General / Car data / Security
   SettingsScreen -> General / Car data / Security
@@ -142,6 +146,7 @@ Android (Kotlin)
 | M14 | Song-progress ring, scrolling now-playing title, notification-access Settings card | Done |
 | M14 | Song-progress ring, scrolling now-playing title, notification-access Settings card | Done |
 | M15 | Keep-awake Settings switch (default on), night mode, 1280×720 layout | Done |
 | M15 | Keep-awake Settings switch (default on), night mode, 1280×720 layout | Done |
 | M16 | GPS speed fallback when no OBD adapter and location is already allowed | Done |
 | M16 | GPS speed fallback when no OBD adapter and location is already allowed | Done |
+| M17 | Non-blocking Gogs update check; 1.0.1 version-only update-test release | Done |
 
 
 ## 5. Verification checklist
 ## 5. Verification checklist
 
 
@@ -190,7 +195,8 @@ Android (Kotlin)
 - [x] Settings → Media grants notification access and shows Granted.
 - [x] Settings → Media grants notification access and shows Granted.
 - [x] Settings gear is next to Wi-Fi; the grid ends with All apps.
 - [x] Settings gear is next to Wi-Fi; the grid ends with All apps.
 - [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] Release 1.0.0 universal APK built and added to `releases/`; 0.10.0 remains installed on the emulator/tablet.
+- [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] 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
@@ -282,6 +288,9 @@ Android (Kotlin)
 - 2026-10-29: Added GPS speed fallback when no OBD adapter is connected and fine
 - 2026-10-29: Added GPS speed fallback when no OBD adapter is connected and fine
   location is already allowed. No permission prompt; stationary values below
   location is already allowed. No permission prompt; stationary values below
   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
+  updater build on emulator/tablet. Published 1.0.1+13 as a version-only update
+  test; 50 tests passed.
 - 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

+ 6 - 0
lib/app.dart

@@ -13,6 +13,7 @@ import 'features/home/launcher_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/weather_controller.dart';
 import 'features/home/weather_controller.dart';
+import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
 import 'features/lock/lock_controller.dart';
 import 'features/lock/lock_screen.dart';
 import 'features/lock/lock_screen.dart';
 
 
@@ -34,6 +35,7 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
       context.read<SystemController>().start();
       context.read<SystemController>().start();
       unawaited(context.read<ObdController>().start());
       unawaited(context.read<ObdController>().start());
       unawaited(context.read<GpsSpeedController>().start());
       unawaited(context.read<GpsSpeedController>().start());
+      unawaited(context.read<UpdateController>().start());
       unawaited(context.read<WeatherController>().start());
       unawaited(context.read<WeatherController>().start());
     });
     });
   }
   }
@@ -56,7 +58,11 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
         unawaited(context.read<DisplayController>().apply());
         unawaited(context.read<DisplayController>().apply());
         unawaited(context.read<ObdController>().start());
         unawaited(context.read<ObdController>().start());
         unawaited(context.read<GpsSpeedController>().start());
         unawaited(context.read<GpsSpeedController>().start());
+        unawaited(context.read<UpdateController>().start());
+        unawaited(context.read<UpdateController>().start());
         unawaited(context.read<GpsSpeedController>().start());
         unawaited(context.read<GpsSpeedController>().start());
+        unawaited(context.read<UpdateController>().start());
+        unawaited(context.read<UpdateController>().start());
         unawaited(context.read<WeatherController>().start());
         unawaited(context.read<WeatherController>().start());
         context.read<LauncherController>().refresh();
         context.read<LauncherController>().refresh();
       case AppLifecycleState.inactive:
       case AppLifecycleState.inactive:

+ 2 - 0
lib/core/app_version.dart

@@ -0,0 +1,2 @@
+/// Keep this in sync with the `version` in pubspec.yaml.
+const String appVersion = '1.0.1';

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

@@ -0,0 +1,19 @@
+import 'package:flutter/foundation.dart';
+
+/// The latest published Gogs release relevant to the update check.
+@immutable
+class UpdateInfo {
+  const UpdateInfo({
+    required this.currentVersion,
+    required this.latestVersion,
+    required this.available,
+    required this.releasePageUrl,
+    required this.downloadUrl,
+  });
+
+  final String currentVersion;
+  final String latestVersion;
+  final bool available;
+  final String releasePageUrl;
+  final String downloadUrl;
+}

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

@@ -43,6 +43,8 @@ abstract class LauncherPlatform {
   Future<bool> openNotificationAccessSettings();
   Future<bool> openNotificationAccessSettings();
 
 
   Future<void> setKeepScreenOn(bool enabled);
   Future<void> setKeepScreenOn(bool enabled);
+
+  Future<bool> openUrl(String url);
 }
 }
 
 
 class NativeBridge implements LauncherPlatform {
 class NativeBridge implements LauncherPlatform {
@@ -193,6 +195,18 @@ class NativeBridge implements LauncherPlatform {
     }
     }
   }
   }
 
 
+  @override
+  Future<bool> openUrl(String url) async {
+    try {
+      return await _appsChannel.invokeMethod<bool>('openUrl', {'url': url}) ??
+          false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
   @override
   @override
   Future<void> setKeepScreenOn(bool enabled) async {
   Future<void> setKeepScreenOn(bool enabled) async {
     try {
     try {

+ 117 - 0
lib/data/services/update_service.dart

@@ -0,0 +1,117 @@
+import 'dart:convert';
+import 'dart:io';
+
+import '../models/update_info.dart';
+
+class UpdateException implements Exception {
+  UpdateException(this.message);
+
+  final String message;
+
+  @override
+  String toString() => message;
+}
+
+/// Checks the public Gogs releases API. This is a notification only; installing
+/// an update remains a user action from the release page.
+class UpdateService {
+  static const repository = 'kkazakov/car-interface-android';
+  static const _baseUrl = 'https://git.krasi.net';
+  static const releasePageUrl = '$_baseUrl/$repository/releases';
+
+  Future<UpdateInfo> check(String currentVersion) async {
+    final uri = Uri.parse('$_baseUrl/api/v1/repos/$repository/releases');
+    final client = HttpClient()..connectionTimeout = const Duration(seconds: 8);
+    try {
+      final request = await client.getUrl(uri);
+      request.headers.set(
+        HttpHeaders.userAgentHeader,
+        'CarDash/$currentVersion',
+      );
+      final response = await request.close().timeout(
+        const Duration(seconds: 10),
+      );
+      if (response.statusCode != HttpStatus.ok) {
+        throw UpdateException(
+          'Release server returned ${response.statusCode}.',
+        );
+      }
+      final body = await response.transform(utf8.decoder).join();
+      final decoded = jsonDecode(body);
+      if (decoded is! List) {
+        throw UpdateException('Unexpected release response.');
+      }
+
+      final releases = <_Release>[];
+      for (final raw in decoded) {
+        if (raw is! Map) continue;
+        if (raw['draft'] == true || raw['prerelease'] == true) continue;
+        final tag = (raw['tag_name'] as String?)?.trim() ?? '';
+        final version = _normaliseVersion(tag);
+        if (version == null) continue;
+        releases.add(
+          _Release(
+            version: version,
+            title: (raw['name'] as String?)?.trim() ?? tag,
+          ),
+        );
+      }
+      if (releases.isEmpty) {
+        throw UpdateException('No published releases found.');
+      }
+      releases.sort((a, b) => compareVersions(b.version, a.version));
+      final latest = releases.first;
+      final available = compareVersions(latest.version, currentVersion) > 0;
+      return UpdateInfo(
+        currentVersion: currentVersion,
+        latestVersion: latest.version,
+        available: available,
+        releasePageUrl: releasePageUrl,
+        downloadUrl:
+            '$_baseUrl/$repository/raw/main/releases/'
+            'cardash-android-${latest.version}.apk',
+      );
+    } on UpdateException {
+      rethrow;
+    } catch (error) {
+      throw UpdateException('Could not check for updates: $error');
+    } finally {
+      client.close(force: true);
+    }
+  }
+
+  static String? _normaliseVersion(String tag) {
+    final value = tag.replaceFirst(RegExp(r'^[vV]'), '').trim();
+    return RegExp(r'^\d+\.\d+\.\d+$').hasMatch(value) ? value : null;
+  }
+}
+
+/// Compares dot-separated numeric versions. Returns <0 when [a] is older.
+int compareVersions(String a, String b) {
+  List<int> parts(String value) {
+    final clean = value.replaceFirst(RegExp(r'^[vV]'), '');
+    final numbers = clean
+        .split('.')
+        .map((part) => int.tryParse(part) ?? 0)
+        .toList();
+    while (numbers.length < 3) {
+      numbers.add(0);
+    }
+    return numbers;
+  }
+
+  final left = parts(a);
+  final right = parts(b);
+  for (var index = 0; index < 3; index++) {
+    final compared = left[index].compareTo(right[index]);
+    if (compared != 0) return compared;
+  }
+  return 0;
+}
+
+class _Release {
+  const _Release({required this.version, required this.title});
+
+  final String version;
+  final String title;
+}

+ 109 - 1
lib/features/settings/settings_screen.dart

@@ -1,6 +1,7 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:provider/provider.dart';
 import 'package:provider/provider.dart';
 
 
+import '../../core/app_version.dart';
 import '../../core/display_controller.dart';
 import '../../core/display_controller.dart';
 import '../../core/theme/accent_choice.dart';
 import '../../core/theme/accent_choice.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors.dart';
@@ -13,6 +14,7 @@ import '../../data/models/slot_type.dart';
 import '../apps/app_picker_sheet.dart';
 import '../apps/app_picker_sheet.dart';
 import '../home/launcher_controller.dart';
 import '../home/launcher_controller.dart';
 import '../home/system_controller.dart';
 import '../home/system_controller.dart';
+import 'update_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
 import '../lock/pin_setup_screen.dart';
 import 'car_data_section.dart';
 import 'car_data_section.dart';
@@ -28,6 +30,7 @@ class SettingsScreen extends StatelessWidget {
     final display = context.watch<DisplayController>();
     final display = context.watch<DisplayController>();
     final launcher = context.watch<LauncherController>();
     final launcher = context.watch<LauncherController>();
     final system = context.watch<SystemController>();
     final system = context.watch<SystemController>();
+    final update = context.watch<UpdateController>();
     final lock = context.watch<LockController>();
     final lock = context.watch<LockController>();
 
 
     return Scaffold(
     return Scaffold(
@@ -331,10 +334,21 @@ class SettingsScreen extends StatelessWidget {
                         ],
                         ],
                       ),
                       ),
                     ),
                     ),
+                    const SizedBox(height: 30),
+                    _SectionTitle(
+                      title: 'Updates',
+                      colors: colors,
+                      theme: theme,
+                    ),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 20, 26, 20),
+                      child: _UpdateRow(update: update),
+                    ),
                     const SizedBox(height: 28),
                     const SizedBox(height: 28),
                     Center(
                     Center(
                       child: Text(
                       child: Text(
-                        'CarDash 1.0.0 · Android 10+',
+                        'CarDash $appVersion · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                         style: theme.textTheme.bodySmall?.copyWith(
                           color: colors.inkFaint,
                           color: colors.inkFaint,
                         ),
                         ),
@@ -697,6 +711,100 @@ class _BatteryToggleRow extends StatelessWidget {
   }
   }
 }
 }
 
 
+class _UpdateRow extends StatelessWidget {
+  const _UpdateRow({required this.update});
+
+  final UpdateController update;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final available = update.updateAvailable;
+    final status = 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;
+
+    return Row(
+      children: [
+        Container(
+          width: 42,
+          height: 42,
+          decoration: BoxDecoration(
+            color: available ? colors.accentSoft : colors.surfaceStrong,
+            borderRadius: BorderRadius.circular(14),
+            border: Border.all(color: colors.border),
+          ),
+          alignment: Alignment.center,
+          child: AppIcon(
+            available ? 'assets/icons/cloud.svg' : 'assets/icons/check.svg',
+            size: 20,
+            color: available ? colors.accent : colors.inkMuted,
+          ),
+        ),
+        const SizedBox(width: 14),
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('CarDash updates', style: theme.textTheme.titleMedium),
+              const SizedBox(height: 2),
+              Text(
+                status,
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: available ? colors.accent : colors.inkMuted,
+                ),
+              ),
+              if (detail != null) ...[
+                const SizedBox(height: 2),
+                Text(
+                  detail,
+                  style: theme.textTheme.bodySmall?.copyWith(
+                    color: colors.inkFaint,
+                  ),
+                ),
+              ],
+            ],
+          ),
+        ),
+        const SizedBox(width: 10),
+        if (update.checking)
+          SizedBox(
+            width: 20,
+            height: 20,
+            child: CircularProgressIndicator(
+              strokeWidth: 2,
+              color: colors.accent,
+            ),
+          )
+        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.'),
+                  ),
+                );
+              }
+            },
+          )
+        else
+          _SmallButton(label: 'Check', onTap: update.check),
+      ],
+    );
+  }
+}
+
 class _NowPlayingAccessRow extends StatelessWidget {
 class _NowPlayingAccessRow extends StatelessWidget {
   const _NowPlayingAccessRow({required this.system});
   const _NowPlayingAccessRow({required this.system});
 
 

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

@@ -0,0 +1,74 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../core/app_version.dart';
+import '../../data/models/update_info.dart';
+import '../../data/services/native_bridge.dart';
+import '../../data/services/update_service.dart';
+
+/// Non-blocking update check against the public Gogs releases API.
+class UpdateController extends ChangeNotifier {
+  UpdateController(this._platform, {UpdateService? service})
+    : _service = service ?? UpdateService();
+
+  final LauncherPlatform _platform;
+  final UpdateService _service;
+
+  UpdateInfo? _result;
+  bool _checking = false;
+  bool _started = false;
+  bool _disposed = false;
+  String? _error;
+
+  String get currentVersion => appVersion;
+
+  String? get latestVersion => _result?.latestVersion;
+
+  bool get checking => _checking;
+
+  bool get updateAvailable => _result?.available ?? false;
+
+  String? get error => _error;
+
+  String? get releasePageUrl => _result?.releasePageUrl;
+
+  Future<void> start() async {
+    if (_started) return;
+    _started = true;
+    await check();
+  }
+
+  Future<void> check() async {
+    if (_checking) return;
+    _checking = true;
+    _error = null;
+    _notify();
+    try {
+      _result = await _service.check(appVersion);
+    } on UpdateException catch (problem) {
+      _error = problem.message;
+    } catch (_) {
+      _error = 'Could not check for updates.';
+    } finally {
+      _checking = false;
+      _notify();
+    }
+  }
+
+  Future<bool> openReleasePage() {
+    return _platform.openUrl(
+      _result?.releasePageUrl ?? UpdateService.releasePageUrl,
+    );
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    super.dispose();
+  }
+}

+ 3 - 0
lib/main.dart

@@ -16,6 +16,7 @@ import 'features/home/launcher_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/weather_controller.dart';
 import 'features/home/weather_controller.dart';
+import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
 import 'features/lock/lock_controller.dart';
 
 
 Future<void> main() async {
 Future<void> main() async {
@@ -49,6 +50,7 @@ Future<void> main() async {
   final systemController = SystemController(platform);
   final systemController = SystemController(platform);
   final obdController = ObdController(telemetry, settings)..initialize();
   final obdController = ObdController(telemetry, settings)..initialize();
   final gpsSpeedController = GpsSpeedController(telemetry);
   final gpsSpeedController = GpsSpeedController(telemetry);
+  final updateController = UpdateController(platform);
   final weatherController = WeatherController(telemetry, settings)
   final weatherController = WeatherController(telemetry, settings)
     ..initialize();
     ..initialize();
   final lockController = LockController(PinService(FlutterSecureStore()));
   final lockController = LockController(PinService(FlutterSecureStore()));
@@ -64,6 +66,7 @@ Future<void> main() async {
         ChangeNotifierProvider.value(value: obdController),
         ChangeNotifierProvider.value(value: obdController),
         ChangeNotifierProvider.value(value: gpsSpeedController),
         ChangeNotifierProvider.value(value: gpsSpeedController),
         ChangeNotifierProvider.value(value: weatherController),
         ChangeNotifierProvider.value(value: weatherController),
+        ChangeNotifierProvider.value(value: updateController),
         ChangeNotifierProvider.value(value: lockController),
         ChangeNotifierProvider.value(value: lockController),
       ],
       ],
       child: const CarDashApp(),
       child: const CarDashApp(),

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
 publish_to: "none"
-version: 1.0.0+12
+version: 1.0.1+13
 
 
 environment:
 environment:
   sdk: ^3.13.7
   sdk: ^3.13.7

+ 13 - 6
releases/README.md

@@ -1,18 +1,25 @@
-# CarDash 1.0.0 — universal Android release
+# CarDash releases
+
+## 1.0.1 — update-test release
+
+- **File:** `cardash-android-1.0.1.apk`
+- **Version:** 1.0.1 (versionCode 13)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `fd743d120297a95979eb3117d8d19d0cb95c3dfd96db9405a2f8a2821bf79b0c`
+- **Changes:** version bump only; no functional changes from the 1.0.0 updater build.
+
+## 1.0.0 — first universal release
 
 
 - **File:** `cardash-android-1.0.0.apk`
 - **File:** `cardash-android-1.0.0.apk`
-- **Package:** `com.wasp.cardash_launcher`
 - **Version:** 1.0.0 (versionCode 12)
 - **Version:** 1.0.0 (versionCode 12)
-- **Size:** 54M
-- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
 - **SHA-256:** `9a75258e14432d74eedb866ac4043179496234c252a877d25b709c2a2ff4d9a2`
 - **SHA-256:** `9a75258e14432d74eedb866ac4043179496234c252a877d25b709c2a2ff4d9a2`
 - **Source:** commit `d28bfea`
 - **Source:** commit `d28bfea`
 
 
 The release is published at:
 The release is published at:
 <https://git.krasi.net/kkazakov/car-interface-android/releases>
 <https://git.krasi.net/kkazakov/car-interface-android/releases>
 
 
-Gogs limits release attachments to 32 MB, so this 54 MB universal APK is linked
-directly from the repository rather than attached to the release.
+Gogs limits release attachments to 32 MB, so these 54 MB universal APKs are
+linked directly from the repository rather than attached to the release.
 
 
 This is a single universal APK containing all supported Android ABIs. It is
 This is a single universal APK containing all supported Android ABIs. It is
 signed with the project's existing debug signing configuration, so it is suitable
 signed with the project's existing debug signing configuration, so it is suitable

BIN
releases/cardash-android-1.0.1.apk


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

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

+ 3 - 0
test/display_controller_test.dart

@@ -50,6 +50,9 @@ class _DisplayPlatform implements LauncherPlatform {
   Future<void> setKeepScreenOn(bool enabled) async {
   Future<void> setKeepScreenOn(bool enabled) async {
     calls.add(enabled);
     calls.add(enabled);
   }
   }
+
+  @override
+  Future<bool> openUrl(String url) async => true;
 }
 }
 
 
 void main() {
 void main() {

+ 3 - 0
test/launcher_controller_test.dart

@@ -51,6 +51,9 @@ class FakeLauncherPlatform implements LauncherPlatform {
 
 
   @override
   @override
   Future<void> setKeepScreenOn(bool enabled) async {}
   Future<void> setKeepScreenOn(bool enabled) async {}
+
+  @override
+  Future<bool> openUrl(String url) async => true;
 }
 }
 
 
 List<AppEntry> _apps(int count) {
 List<AppEntry> _apps(int count) {

+ 3 - 0
test/system_controller_test.dart

@@ -53,6 +53,9 @@ class _MediaPlatform implements LauncherPlatform {
 
 
   @override
   @override
   Future<void> setKeepScreenOn(bool enabled) async {}
   Future<void> setKeepScreenOn(bool enabled) async {}
+
+  @override
+  Future<bool> openUrl(String url) async => true;
 }
 }
 
 
 void main() {
 void main() {

+ 113 - 0
test/update_controller_test.dart

@@ -0,0 +1,113 @@
+import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:cardash_launcher/data/models/system_status.dart';
+import 'package:cardash_launcher/data/models/update_info.dart';
+import 'package:cardash_launcher/data/services/native_bridge.dart';
+import 'package:cardash_launcher/data/services/update_service.dart';
+import 'package:cardash_launcher/features/settings/update_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+class _UpdatePlatform implements LauncherPlatform {
+  final List<String> opened = [];
+
+  @override
+  Future<bool> openUrl(String url) async {
+    opened.add(url);
+    return true;
+  }
+
+  @override
+  Future<List<AppEntry>> listApps() async => const [];
+
+  @override
+  Future<void> launchApp({
+    required String packageName,
+    required String activityName,
+  }) async {}
+
+  @override
+  Future<bool> isDefaultHome() async => true;
+
+  @override
+  Future<bool> requestDefaultHome() async => true;
+
+  @override
+  Future<SystemStatus> getStatus() async => const SystemStatus();
+
+  @override
+  Future<VolumeInfo> getVolume() async => const VolumeInfo();
+
+  @override
+  Future<void> setVolume(int index) async {}
+
+  @override
+  Future<bool> isMusicPlaying() async => false;
+
+  @override
+  Future<bool> sendMediaKey(String action) async => true;
+
+  @override
+  Future<MediaPlayback> getMediaState() async => const MediaPlayback();
+
+  @override
+  Future<bool> openNotificationAccessSettings() async => true;
+
+  @override
+  Future<void> setKeepScreenOn(bool enabled) async {}
+}
+
+class _FakeUpdateService extends UpdateService {
+  _FakeUpdateService(this.result);
+
+  final UpdateInfo result;
+
+  @override
+  Future<UpdateInfo> check(String currentVersion) async => result;
+}
+
+void main() {
+  test('compares semantic versions', () {
+    expect(compareVersions('1.0.1', '1.0.0'), greaterThan(0));
+    expect(compareVersions('v1.0.0', '1.0.0'), 0);
+    expect(compareVersions('1.0.0', '1.0.1'), lessThan(0));
+  });
+
+  test('reports an available update and opens the release page', () async {
+    final platform = _UpdatePlatform();
+    final service = _FakeUpdateService(
+      const UpdateInfo(
+        currentVersion: '1.0.0',
+        latestVersion: '1.0.1',
+        available: true,
+        releasePageUrl: 'https://example.test/releases',
+        downloadUrl: 'https://example.test/app.apk',
+      ),
+    );
+    final controller = UpdateController(platform, service: service);
+    addTearDown(controller.dispose);
+
+    await controller.check();
+    expect(controller.updateAvailable, isTrue);
+    expect(controller.latestVersion, '1.0.1');
+    expect(await controller.openReleasePage(), isTrue);
+    expect(platform.opened.single, 'https://example.test/releases');
+  });
+
+  test('reports up to date when no newer release exists', () async {
+    final service = _FakeUpdateService(
+      const UpdateInfo(
+        currentVersion: '1.0.0',
+        latestVersion: '1.0.0',
+        available: false,
+        releasePageUrl: 'https://example.test/releases',
+        downloadUrl: 'https://example.test/app.apk',
+      ),
+    );
+    final controller = UpdateController(_UpdatePlatform(), service: service);
+    addTearDown(controller.dispose);
+
+    await controller.check();
+    expect(controller.updateAvailable, isFalse);
+    expect(controller.error, isNull);
+  });
+}