Pārlūkot izejas kodu

feat: open Bluetooth settings when off and release 1.0.2

Add Open Bluetooth settings to the Car data card when the adapter is off and refresh paired devices when the app resumes. Document the Gogs release/deployment runbook in AGENTS.md. Bump to 1.0.2+14 and add the universal APK/checksum to releases/.
Krasimir Kazakov 1 nedēļu atpakaļ
vecāks
revīzija
a6e692d0b7

+ 20 - 0
AGENTS.md

@@ -102,3 +102,23 @@ notifyListeners();
 - 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.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.
+
+## 7. Releasing and deploying updates
+
+Remote: `origin` = `https://git.krasi.net/kkazakov/car-interface-android.git`, branch `main`. Push with `git push origin main`; never force-push. The host runs **Gogs** (not Gitea); the public Releases page is `https://git.krasi.net/kkazakov/car-interface-android/releases`.
+
+Release checklist:
+
+1. Keep `version:` in `pubspec.yaml` and `appVersion` in `lib/core/app_version.dart` identical. Increment versionCode.
+2. Run `./tool/check.sh`.
+3. Build the universal APK:
+   `JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release --target-platform android-arm,android-arm64,android-x64`
+4. Copy to `releases/cardash-android-<version>.apk`, write `releases/cardash-android-<version>.apk.sha256`, update `releases/README.md`, then commit and push.
+5. Create a Gogs release at `/releases/new`: tag `v<version>`, target `main`, title/body, link the raw APK:
+   `https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-<version>.apk`.
+   Gogs attachments cap at 32 MB, so the ~54 MB universal APK is linked from the release body instead of attached.
+6. Publishing the Gogs release is the deployment for in-app updates. The app queries
+   `https://git.krasi.net/api/v1/repos/kkazakov/car-interface-android/releases`,
+   selects the highest published semver tag, and downloads
+   `/raw/main/releases/cardash-android-<version>.apk`. No app-side release configuration is needed.
+7. The current release build uses the debug signing config. Android still requires the user to confirm installation and allow unknown-app installs once; never attempt silent installation.

+ 6 - 4
README.md

@@ -18,7 +18,7 @@ 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 releases
-- [cardash-android-1.0.1.apk](releases/cardash-android-1.0.1.apk) — universal Android release
+- [cardash-android-1.0.2.apk](releases/cardash-android-1.0.2.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
@@ -236,6 +236,8 @@ the app resumes.
 **Car data**
 - OBD-II adapter dropdown: None plus paired Bluetooth Classic devices.
 - Connection status and a Find/Allow action for Bluetooth permission.
+- If Bluetooth is off: **Open Bluetooth settings** to turn it on, then return
+  and the paired-device list refreshes automatically.
 - Location temperature toggle: “Show temperature from current location if no
   OBD-II connected.” Enabling it asks for location permission.
 - Read-only note: CarDash never writes to the ECU.
@@ -306,9 +308,9 @@ title, artist, progress and pause/resume were verified against a live Chrome
 MediaSession. The OBD hardware path compiles and is covered by the parser/economy
 design, but no ELM327 adapter is available on this machine; socket behavior still
 needs a car test. Release
-The 1.0.1 universal APK is built from this tree. The emulator completed a live
-1.0.0 → 1.0.1 in-app update and shows the yellow update dot; the tablet is on
-the 1.0.1 dot build.
+The 1.0.2 universal APK is built from this tree. The tablet is on 1.0.1 and
+will pick up 1.0.2 through Settings → Updates; the yellow dot appears when the
+new release is published. The emulator has verified the full in-app update path.
 
 ## Troubleshooting the emulator
 

+ 14 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/TelemetryBridge.kt

@@ -4,6 +4,7 @@ import android.Manifest
 import android.app.Activity
 import android.bluetooth.BluetoothAdapter
 import android.bluetooth.BluetoothManager
+import android.content.Intent
 import android.content.pm.PackageManager
 import android.location.Geocoder
 import android.location.Location
@@ -13,6 +14,7 @@ import android.os.Build
 import android.os.Bundle
 import android.os.Handler
 import android.os.Looper
+import android.provider.Settings
 import io.flutter.plugin.common.BinaryMessenger
 import io.flutter.plugin.common.MethodCall
 import io.flutter.plugin.common.MethodChannel
@@ -68,6 +70,7 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         when (call.method) {
             "listBondedDevices" -> listBondedDevices(result)
             "isBluetoothEnabled" -> result.success(bluetoothAdapter()?.isEnabled == true)
+            "openBluetoothSettings" -> result.success(openBluetoothSettings())
             "ensureBluetoothPermission" -> ensurePermission(KIND_BLUETOOTH, result)
             "ensureLocationPermission" -> ensurePermission(KIND_LOCATION, result)
             "getLocation" -> getLocation(result)
@@ -120,6 +123,17 @@ class TelemetryBridge(private val activity: Activity) : MethodChannel.MethodCall
         }
     }
 
+    private fun openBluetoothSettings(): Boolean {
+        return try {
+            val intent = Intent(Settings.ACTION_BLUETOOTH_SETTINGS)
+                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            activity.startActivity(intent)
+            true
+        } catch (_: Throwable) {
+            false
+        }
+    }
+
     private fun hasBluetoothPermission(): Boolean {
         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
         return activity.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==

+ 5 - 0
docs/IMPLEMENTATION_PLAN.md

@@ -147,6 +147,7 @@ Android (Kotlin)
 | 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 |
+| M18 | Bluetooth-off settings action; 1.0.2 release | Done |
 
 ## 5. Verification checklist
 
@@ -199,6 +200,8 @@ Android (Kotlin)
 - [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] Yellow update dot appears on the Settings gear; 51 tests pass.
+- [x] Bluetooth-off Settings card exposes **Open Bluetooth settings** and refreshes on return.
+- [x] 1.0.2 universal APK built and published for in-app update.
 - [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.
 
@@ -295,6 +298,8 @@ Android (Kotlin)
   updater build on emulator/tablet. Published 1.0.1+13 as a version-only update
   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: Added the Bluetooth-off **Open Bluetooth settings** action and
+  release/deployment runbook. Bumped to 1.0.2+14 and published it for in-app update.
 - 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

+ 1 - 1
lib/core/app_version.dart

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

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

@@ -21,6 +21,8 @@ abstract class TelemetryPlatform {
 
   Future<bool> isBluetoothEnabled();
 
+  Future<bool> openBluetoothSettings();
+
   Future<bool> ensureBluetoothPermission();
 
   Future<bool> ensureLocationPermission();
@@ -74,6 +76,18 @@ class NativeTelemetryBridge implements TelemetryPlatform {
     }
   }
 
+  @override
+  Future<bool> openBluetoothSettings() async {
+    try {
+      return await _channel.invokeMethod<bool>('openBluetoothSettings') ??
+          false;
+    } on PlatformException {
+      return false;
+    } on MissingPluginException {
+      return false;
+    }
+  }
+
   @override
   Future<bool> ensureBluetoothPermission() async {
     try {

+ 9 - 0
lib/features/home/obd_controller.dart

@@ -26,6 +26,7 @@ class ObdController extends ChangeNotifier {
   bool _loadingDevices = false;
   String? _devicesError;
   bool _needsBluetoothPermission = false;
+  bool? _bluetoothEnabled;
 
   ObdState _state = const ObdState();
   String? _localError;
@@ -45,6 +46,8 @@ class ObdController extends ChangeNotifier {
 
   bool get needsBluetoothPermission => _needsBluetoothPermission;
 
+  bool? get bluetoothEnabled => _bluetoothEnabled;
+
   bool get connected => _state.connected;
 
   bool get connecting => _state.connecting;
@@ -60,6 +63,7 @@ class ObdController extends ChangeNotifier {
   String get statusLabel {
     if (_loadingDevices) return 'Checking paired devices…';
     if (_needsBluetoothPermission) return 'Bluetooth permission needed';
+    if (_bluetoothEnabled == false) return 'Bluetooth is off';
     if (connecting) return 'Connecting…';
     if (connected) {
       return isFresh ? 'Connected' : 'Connected — waiting for data';
@@ -144,6 +148,7 @@ class ObdController extends ChangeNotifier {
         }
       }
       final enabled = await _platform.isBluetoothEnabled();
+      _bluetoothEnabled = enabled;
       if (!enabled) {
         _needsBluetoothPermission = false;
         _devicesError = 'Bluetooth is turned off.';
@@ -165,6 +170,10 @@ class ObdController extends ChangeNotifier {
     }
   }
 
+  Future<bool> openBluetoothSettings() {
+    return _platform.openBluetoothSettings();
+  }
+
   Future<void> _connect() async {
     final address = _selectedAddress;
     if (address == null) return;

+ 40 - 1
lib/features/settings/car_data_section.dart

@@ -17,10 +17,12 @@ class CarDataSection extends StatefulWidget {
   State<CarDataSection> createState() => _CarDataSectionState();
 }
 
-class _CarDataSectionState extends State<CarDataSection> {
+class _CarDataSectionState extends State<CarDataSection>
+    with WidgetsBindingObserver {
   @override
   void initState() {
     super.initState();
+    WidgetsBinding.instance.addObserver(this);
     WidgetsBinding.instance.addPostFrameCallback((_) {
       if (!mounted) return;
       unawaited(
@@ -29,6 +31,21 @@ class _CarDataSectionState extends State<CarDataSection> {
     });
   }
 
+  @override
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    super.dispose();
+  }
+
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed) {
+      unawaited(
+        context.read<ObdController>().refreshDevices(requestPermission: false),
+      );
+    }
+  }
+
   @override
   Widget build(BuildContext context) {
     final colors = context.colors;
@@ -167,6 +184,28 @@ class _CarDataSectionState extends State<CarDataSection> {
             style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
           ),
         ],
+        if (obd.bluetoothEnabled == false) ...[
+          const SizedBox(height: 2),
+          TextButton.icon(
+            onPressed: () async {
+              final opened = await obd.openBluetoothSettings();
+              if (!opened && context.mounted) {
+                _message(context, 'Could not open Bluetooth settings.');
+              }
+            },
+            icon: AppIcon(
+              'assets/icons/settings.svg',
+              size: 16,
+              color: colors.accent,
+            ),
+            label: const Text('Open Bluetooth settings'),
+            style: TextButton.styleFrom(
+              foregroundColor: colors.accent,
+              padding: EdgeInsets.zero,
+              alignment: Alignment.centerLeft,
+            ),
+          ),
+        ],
         const SizedBox(height: 8),
         Text(
           'Pair the ELM327 in Android Bluetooth settings first. CarDash only '

+ 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.1+13
+version: 1.0.2+14
 
 environment:
   sdk: ^3.13.7

+ 9 - 0
releases/README.md

@@ -1,5 +1,14 @@
 # CarDash releases
 
+## 1.0.2 — Bluetooth settings action
+
+- **File:** `cardash-android-1.0.2.apk`
+- **Version:** 1.0.2 (versionCode 14)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `9cecb410c9d6af6e83f13c63d3c9839614f03505ccb47682b646a1e461da916c`
+- **Changes:** adds **Open Bluetooth settings** when Bluetooth is off; no changes
+  to the update installer.
+
 ## 1.0.1 — update-test release
 
 - **File:** `cardash-android-1.0.1.apk`

BIN
releases/cardash-android-1.0.2.apk


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

@@ -0,0 +1 @@
+9cecb410c9d6af6e83f13c63d3c9839614f03505ccb47682b646a1e461da916c  releases/cardash-android-1.0.2.apk

+ 3 - 0
test/gps_speed_test.dart

@@ -45,6 +45,9 @@ class _GpsPlatform implements TelemetryPlatform {
   @override
   Future<bool> ensureBluetoothPermission() async => false;
 
+  @override
+  Future<bool> openBluetoothSettings() async => true;
+
   @override
   Future<bool> ensureLocationPermission() async => permission;