Forráskód Böngészése

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 hete
szülő
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/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/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, equalizer, night overlay.
 - `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`.
 - `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.
@@ -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.
 - 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.
 - 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.

+ 22 - 7
README.md

@@ -17,8 +17,8 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 
 ## 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)
 
 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
   audio stops the scroll and holds its position. When nothing is playing it
   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,
   used only to read the active media session’s title, duration and position.
   CarDash never reads or stores notification content.
@@ -232,6 +236,11 @@ the app resumes.
   OBD-II connected.” Enabling it asks for location permission.
 - 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**
 - Set, change or remove a four-digit PIN.
 - 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
 ```
 
-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,
 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
 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
 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
@@ -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
 design, but no ELM327 adapter is available on this machine; socket behavior still
 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
 
@@ -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
   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.
 - **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.
@@ -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
   for a launcher but requires a Play policy declaration if the app is ever
   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
   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.Canvas
 import android.graphics.drawable.Drawable
+import android.net.Uri
 import android.os.Handler
 import android.os.Looper
 import android.provider.Settings
@@ -53,10 +54,27 @@ class LauncherBridge(private val activity: Activity) : MethodChannel.MethodCallH
             "launchApp" -> launchApp(call, result)
             "isDefaultHome" -> result.success(isDefaultHome())
             "requestDefaultHome" -> result.success(requestDefaultHome())
+            "openUrl" -> openUrl(call, result)
             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) {
         executor.execute {
             val apps = try {

+ 10 - 1
docs/IMPLEMENTATION_PLAN.md

@@ -40,6 +40,8 @@ compatibility:
   3°C; no row when unavailable.
 - GPS speed fallback when no OBD adapter is connected and precise location was
   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.
 - PIN is requested **only on a cold start**, never on resume or screen-off.
 - 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. |
 | 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. |
 | 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. |
@@ -98,6 +101,7 @@ Flutter
     SystemController    -> battery/Wi-Fi/volume/media state + media keys
     DisplayController   -> persisted keep-awake window flag
     GpsSpeedController  -> GPS speed fallback (permission already granted)
+    UpdateController    -> public Gogs release check + release-page link
     HomeScreen          -> NightModeOverlay on empty-background long press
   AllAppsScreen / AppPickerSheet
   SettingsScreen -> General / Car data / Security
@@ -142,6 +146,7 @@ Android (Kotlin)
 | 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 |
 | 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
 
@@ -190,7 +195,8 @@ Android (Kotlin)
 - [x] Settings → Media grants notification access and shows Granted.
 - [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] 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.
 
 ## 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
   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.
+- 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
   `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

+ 6 - 0
lib/app.dart

@@ -13,6 +13,7 @@ import 'features/home/launcher_controller.dart';
 import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/weather_controller.dart';
+import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
 import 'features/lock/lock_screen.dart';
 
@@ -34,6 +35,7 @@ class _CarDashAppState extends State<CarDashApp> with WidgetsBindingObserver {
       context.read<SystemController>().start();
       unawaited(context.read<ObdController>().start());
       unawaited(context.read<GpsSpeedController>().start());
+      unawaited(context.read<UpdateController>().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<ObdController>().start());
         unawaited(context.read<GpsSpeedController>().start());
+        unawaited(context.read<UpdateController>().start());
+        unawaited(context.read<UpdateController>().start());
         unawaited(context.read<GpsSpeedController>().start());
+        unawaited(context.read<UpdateController>().start());
+        unawaited(context.read<UpdateController>().start());
         unawaited(context.read<WeatherController>().start());
         context.read<LauncherController>().refresh();
       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<void> setKeepScreenOn(bool enabled);
+
+  Future<bool> openUrl(String url);
 }
 
 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
   Future<void> setKeepScreenOn(bool enabled) async {
     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:provider/provider.dart';
 
+import '../../core/app_version.dart';
 import '../../core/display_controller.dart';
 import '../../core/theme/accent_choice.dart';
 import '../../core/theme/app_colors.dart';
@@ -13,6 +14,7 @@ import '../../data/models/slot_type.dart';
 import '../apps/app_picker_sheet.dart';
 import '../home/launcher_controller.dart';
 import '../home/system_controller.dart';
+import 'update_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
 import 'car_data_section.dart';
@@ -28,6 +30,7 @@ class SettingsScreen extends StatelessWidget {
     final display = context.watch<DisplayController>();
     final launcher = context.watch<LauncherController>();
     final system = context.watch<SystemController>();
+    final update = context.watch<UpdateController>();
     final lock = context.watch<LockController>();
 
     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),
                     Center(
                       child: Text(
-                        'CarDash 1.0.0 · Android 10+',
+                        'CarDash $appVersion · Android 10+',
                         style: theme.textTheme.bodySmall?.copyWith(
                           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 {
   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/system_controller.dart';
 import 'features/home/weather_controller.dart';
+import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
 
 Future<void> main() async {
@@ -49,6 +50,7 @@ Future<void> main() async {
   final systemController = SystemController(platform);
   final obdController = ObdController(telemetry, settings)..initialize();
   final gpsSpeedController = GpsSpeedController(telemetry);
+  final updateController = UpdateController(platform);
   final weatherController = WeatherController(telemetry, settings)
     ..initialize();
   final lockController = LockController(PinService(FlutterSecureStore()));
@@ -64,6 +66,7 @@ Future<void> main() async {
         ChangeNotifierProvider.value(value: obdController),
         ChangeNotifierProvider.value(value: gpsSpeedController),
         ChangeNotifierProvider.value(value: weatherController),
+        ChangeNotifierProvider.value(value: updateController),
         ChangeNotifierProvider.value(value: lockController),
       ],
       child: const CarDashApp(),

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 1.0.0+12
+version: 1.0.1+13
 
 environment:
   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`
-- **Package:** `com.wasp.cardash_launcher`
 - **Version:** 1.0.0 (versionCode 12)
-- **Size:** 54M
-- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
 - **SHA-256:** `9a75258e14432d74eedb866ac4043179496234c252a877d25b709c2a2ff4d9a2`
 - **Source:** commit `d28bfea`
 
 The release is published at:
 <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
 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 {
     calls.add(enabled);
   }
+
+  @override
+  Future<bool> openUrl(String url) async => true;
 }
 
 void main() {

+ 3 - 0
test/launcher_controller_test.dart

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

+ 3 - 0
test/system_controller_test.dart

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