173 lines
6.0 KiB
Dart
173 lines
6.0 KiB
Dart
// 🛡️ 화면이 꺼져도 동작하는 포그라운드 백그라운드 서비스.
|
|
// 조도 센서로 주머니 속 밝기를 측정해 무단 반출을 감지하고, 진동+알림+서버 신고를 처리한다.
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:ui';
|
|
import 'package:flutter_background_service/flutter_background_service.dart';
|
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:light/light.dart';
|
|
import 'package:vibration/vibration.dart';
|
|
import 'config.dart' show baseUrl;
|
|
|
|
// 🔦 조도 센서 판정 관련 튜닝 값 (nfc_poccket_checkin_screen.dart와 동일한 기준)
|
|
const int _calibrationSampleCount = 3;
|
|
const Duration _placePhoneDelay = Duration(seconds: 4);
|
|
const int _luxJumpThreshold = 40;
|
|
|
|
const String pocketWatchNotificationChannelId = 'pocket_watch_channel';
|
|
const int pocketWatchNotificationId = 9001;
|
|
|
|
final FlutterLocalNotificationsPlugin _localNotifications =
|
|
FlutterLocalNotificationsPlugin();
|
|
|
|
/// 앱 시작(main()) 시 한 번만 호출. 알림 채널 생성 + 백그라운드 서비스 설정.
|
|
Future<void> initializePocketWatchService() async {
|
|
const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
|
pocketWatchNotificationChannelId,
|
|
'주머니 감시 서비스',
|
|
description: '휴대폰 무단 반출 감시 중 상태를 표시합니다.',
|
|
importance: Importance.low, // 조용한 알림 (소리/진동 없음)
|
|
);
|
|
|
|
await _localNotifications
|
|
.resolvePlatformSpecificImplementation<
|
|
AndroidFlutterLocalNotificationsPlugin
|
|
>()
|
|
?.createNotificationChannel(channel);
|
|
|
|
final service = FlutterBackgroundService();
|
|
await service.configure(
|
|
androidConfiguration: AndroidConfiguration(
|
|
onStart: onPocketWatchServiceStart,
|
|
autoStart: false, // NFC 태깅으로 출석 성공했을 때만 수동으로 시작
|
|
autoStartOnBoot: false,
|
|
isForegroundMode: true,
|
|
notificationChannelId: pocketWatchNotificationChannelId,
|
|
initialNotificationTitle: '🛡️ 주머니 감시 대기 중',
|
|
initialNotificationContent: '출석 태깅을 기다리는 중입니다.',
|
|
foregroundServiceNotificationId: pocketWatchNotificationId,
|
|
foregroundServiceTypes: [AndroidForegroundType.specialUse],
|
|
),
|
|
iosConfiguration: IosConfiguration(autoStart: false),
|
|
);
|
|
}
|
|
|
|
@pragma('vm:entry-point')
|
|
void onPocketWatchServiceStart(ServiceInstance service) {
|
|
DartPluginRegistrant.ensureInitialized();
|
|
|
|
StreamSubscription<int>? lightSub;
|
|
final List<int> calibrationSamples = [];
|
|
int? baselineLux;
|
|
String? studentId;
|
|
String? studentName;
|
|
String? pocketNumber;
|
|
bool violated = false;
|
|
|
|
void updateNotification(String title, String content) {
|
|
if (service is AndroidServiceInstance) {
|
|
_localNotifications.show(
|
|
id: pocketWatchNotificationId,
|
|
title: title,
|
|
body: content,
|
|
notificationDetails: const NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
pocketWatchNotificationChannelId,
|
|
'주머니 감시 서비스',
|
|
icon: 'ic_bg_service_small',
|
|
ongoing: true,
|
|
importance: Importance.low,
|
|
priority: Priority.low,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> reportViolation() async {
|
|
// 진동 3연타로 강하게 경고 (백그라운드 서비스에서는 HapticFeedback이 아닌 vibration 패키지를 써야 동작함)
|
|
Vibration.vibrate(pattern: [0, 400, 150, 400, 150, 400]);
|
|
updateNotification("🚨 무단 반출 감지!", "[$pocketNumber] 주머니에서 폰이 감지되지 않습니다.");
|
|
service.invoke('violation_detected', {
|
|
"pocketNumber": pocketNumber,
|
|
"timestamp": DateTime.now().toIso8601String(),
|
|
});
|
|
|
|
try {
|
|
await http.post(
|
|
Uri.parse("$baseUrl/attendance/violation"),
|
|
headers: {"Content-Type": "application/json"},
|
|
body: jsonEncode({
|
|
"studentId": studentId,
|
|
"studentName": studentName,
|
|
"pocketNumber": pocketNumber,
|
|
"type": "PHONE_REMOVED",
|
|
"timestamp": DateTime.now().toIso8601String(),
|
|
}),
|
|
);
|
|
} catch (_) {
|
|
// 백그라운드 서비스에서는 UI로 에러를 띄울 수 없으니 조용히 무시.
|
|
// (기록 자체는 이미 로컬 알림+haptic으로 학생에게 전달됨)
|
|
}
|
|
}
|
|
|
|
void onLightReading(int luxValue) {
|
|
if (violated) return;
|
|
|
|
if (baselineLux == null) {
|
|
calibrationSamples.add(luxValue);
|
|
if (calibrationSamples.length >= _calibrationSampleCount) {
|
|
baselineLux =
|
|
calibrationSamples.reduce((a, b) => a + b) ~/
|
|
calibrationSamples.length;
|
|
updateNotification("🛡️ 감시 중", "[$pocketNumber] 폰이 주머니 안에 있는지 감시 중");
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (luxValue > baselineLux! + _luxJumpThreshold) {
|
|
violated = true;
|
|
lightSub?.cancel();
|
|
reportViolation();
|
|
}
|
|
}
|
|
|
|
Future<void> startWatching() async {
|
|
lightSub?.cancel();
|
|
calibrationSamples.clear();
|
|
baselineLux = null;
|
|
violated = false;
|
|
|
|
updateNotification("📥 주머니에 넣는 중...", "잠시 후 감시가 시작됩니다.");
|
|
|
|
await Future.delayed(_placePhoneDelay);
|
|
|
|
updateNotification("🔎 밝기 측정 중...", "주머니 속 밝기를 기준값으로 설정하는 중입니다.");
|
|
|
|
try {
|
|
await Light().requestAuthorization();
|
|
lightSub = Light().lightSensorStream.listen(
|
|
onLightReading,
|
|
onError: (Object error) {
|
|
updateNotification("❌ 조도 센서 오류", "$error");
|
|
},
|
|
);
|
|
} catch (e) {
|
|
updateNotification("❌ 조도 센서 시작 실패", "$e");
|
|
}
|
|
}
|
|
|
|
service.on('start_watch').listen((event) {
|
|
studentId = event?['studentId'];
|
|
studentName = event?['studentName'];
|
|
pocketNumber = event?['pocketNumber'];
|
|
startWatching();
|
|
});
|
|
|
|
service.on('stop_watch').listen((event) {
|
|
lightSub?.cancel();
|
|
service.stopSelf();
|
|
});
|
|
}
|