을 만들고 있는데 필요한 기능이 이미 페어링된 기기가 연결됐는지 감지하는 기능인데 플러터만으로는 구현이 불가능해서 찾아보게 되었습니다.

어떻게?

  1. 앱이 백그라운드 상태일 때도 블루투스 감지
  2. 앱에서 주기적으로 체크하는 것이 아닌 콜백이나 이벤트로 처리
    • 결정적으로 플러터에서 지원하지 않음
    • 네이티브 코드 작성(BroadcastReceiverEventChannel)

EventChannel

💡 Quotation 네이티브(Android, iOS)와 플러터 간에 지속적인 이벤트 스트림을 주고받을 때 사용하는 통신 방법

⚠️ iOS 는 작성해 보지 않음

네이티브 코드 작성

/PROJECT_DIR/android/src/kotlin/com/example/PROJECT_NAME/MainActivity.kt

안드로이드 네이티브 코드 시작점이 있는 파일인데 이곳에 서비스, 블루투스 연결 감지를 위한 코드를 작성하고 이벤트 채널을 이용하여 플러터 앱으로 전달을 하면 됩니다. 😄

우선 서비스와 블루투스 연결 감지를 위한 것들을 하나의 클래스로 만들기 위해 새로운 파일을 만들었습니다. 원본

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package com.example.engine_oil_timer

import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.IBinder
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.PluginRegistry
import android.bluetooth.BluetoothDevice

class BluetoothForegroundService : Service() {
    companion object {
        private var eventSink: EventChannel.EventSink? = null
    }

    override fun onBind(intent: Intent?): IBinder? = null

    override fun onCreate() {
        super.onCreate()
        startForegroundService()
        registerReceiver(bluetoothReceiver, IntentFilter("android.bluetooth.device.action.ACL_CONNECTED"))
        registerReceiver(bluetoothReceiver, IntentFilter("android.bluetooth.device.action.ACL_DISCONNECTED"))
    }

    override fun onDestroy() {
        super.onDestroy()
        unregisterReceiver(bluetoothReceiver)
    }

    private val bluetoothReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            intent?.action?.let { action ->
                val device: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
                val name = device?.name ?: "Unknown"
                val address = device?.address ?: "Unknown"

                when(action) {
                    "android.bluetooth.device.action.ACL_CONNECTED" -> {
                        val data = mapOf(
                            "event" to "connected",
                            "name" to name,
                            "address" to address
                        )
                        eventSink?.success(data)
                    }
                    "android.bluetooth.device.action.ACL_DISCONNECTED" -> {
                        val data = mapOf(
                            "event" to "disconnected",
                            "name" to name,
                            "address" to address
                        )
                        eventSink?.success(data)
                    }
                }
            }
        }
    }

    private fun startForegroundService() {
        val channelId = "bluetooth_service"
        val channelName = "Bluetooth Service"

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW)
            val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            manager.createNotificationChannel(chan)
        }

        val notification: Notification = Notification.Builder(this, channelId)
            .setContentTitle("엔진 오일 타이머")
            .setContentText("백그라운드에서 작동됩니다.")
            .setSmallIcon(R.mipmap.ic_launcher)
            .build()

        startForeground(1, notification)
    }

    fun setEventSink(sink: EventChannel.EventSink) {
        eventSink = sink
    }
}

MainActivity.kt 에는 최종적으로 채널 설정을 하면 완성 됩니다. 원본

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.engine_oil_timer

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import android.bluetooth.BluetoothDevice
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val BLUETOOTH_EVENT_CHANNEL = "bluetooth/events"
    private var eventSink: EventChannel.EventSink? = null
    private val CHANNEL = "app.channel.shared.data"


    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        // EventChannel 연결
        EventChannel(flutterEngine?.dartExecutor?.binaryMessenger, BLUETOOTH_EVENT_CHANNEL)
            .setStreamHandler(object : EventChannel.StreamHandler {
                override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
                    val service = BluetoothForegroundService()
                    service.setEventSink(events!!)
                }

                override fun onCancel(arguments: Any?) {}
            })

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            if (call.method == "moveToBackground") {
                moveTaskToBack(true)
                result.success(null)
            } else if (call.method == "startService") {
                val serviceIntent = Intent(this, BluetoothForegroundService::class.java)
                startForegroundService(serviceIntent)

                result.success(true)
            }
        }
    }
}

플러터 앱 코드

앱에서는 해당 기능을 싱글턴 클래스에 넣어놓고 스트림에 대한 Listening 설정, 연결 됐을때와 해제 됐을 때 실행해줄 콜백을 추가하여 완료 지었습니다. 원본

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import 'package:engine_oil_timer/utility/system.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart';

class Bluetooth {
  static final Bluetooth _instance = Bluetooth._internal();
  Bluetooth._internal();
  factory Bluetooth() {
    return _instance;
  }

  final EventChannel _eventChannel = EventChannel('bluetooth/events');
  Stopwatch _stopwatch = Stopwatch();

  List<void Function()> _onConnect = [];
  void addOnConnect(void Function() onConnect) => _onConnect.add(onConnect);
  void removeOnConnect(void Function() onConnect) =>
      _onConnect.remove(onConnect);

  List<void Function()> _onDisconnect = [];
  void addOnDisconnect(void Function() onDisconnect) =>
      _onDisconnect.add(onDisconnect);
  void removeOnDisconnect(void Function() onDisconnect) =>
      _onDisconnect.remove(onDisconnect);

  void listen() async {
    _eventChannel
        .receiveBroadcastStream()
        .map((event) => Map<String, dynamic>.from(event))
        .listen((event) {
          final status = event['event'];
          final name = event['name'];
          final address = event['address'];

          print('블루투스 감지 $status: $name ($address)');
          if (System().data.deviceAddress.compareTo(address) == 0) {
            if (status == 'connected') {
              _stopwatch.start();
              _onConnect.forEach((onConnect) {
                onConnect();
              });
            } else if (status == 'disconnected') {
              _stopwatch.stop();
              _onDisconnect.forEach((onDisconnect) {
                onDisconnect();
              });
            }
          }
        });
  }

  Future<List<BluetoothDevice>> getBoundedDevices() async {
    return await FlutterBluetoothSerial.instance.getBondedDevices();
  }

  Future<String> getDeviceName() async {
    String result = "";
    List<BluetoothDevice> devices = await getBoundedDevices();

    for (BluetoothDevice device in devices) {
      if (device.address.compareTo(System().data.deviceAddress) == 0) {
        if (device.name != null) {
          result = device.name ?? "";
          break;
        }
      }
    }

    return result;
  }

  Duration get elapsed => _stopwatch.elapsed;
  String getElapsed() {
    final h = _stopwatch.elapsed.inHours;
    final m = _stopwatch.elapsed.inMinutes % 60;
    final s = _stopwatch.elapsed.inSeconds % 60;
    return '${h.toString().padLeft(2, '0')}:'
        '${m.toString().padLeft(2, '0')}:'
        '${s.toString().padLeft(2, '0')}';
  }
}