374 lines
16 KiB
Kotlin
374 lines
16 KiB
Kotlin
package com.example.avtex.engine
|
||
|
||
import android.Manifest
|
||
import android.app.Notification
|
||
import android.app.PendingIntent
|
||
import android.app.Service
|
||
import android.content.BroadcastReceiver
|
||
import android.content.Context
|
||
import android.content.Intent
|
||
import android.content.IntentFilter
|
||
import android.content.pm.PackageManager
|
||
import android.os.Build
|
||
import android.os.IBinder
|
||
import android.os.PowerManager
|
||
import android.util.Log
|
||
import androidx.core.app.NotificationCompat
|
||
import androidx.core.content.ContextCompat
|
||
import com.example.avtex.AvtexApplication
|
||
import com.example.avtex.MainActivity
|
||
import com.example.avtex.hac.HacClient
|
||
import com.google.android.gms.location.ActivityRecognition
|
||
import com.google.android.gms.location.SleepSegmentRequest
|
||
import kotlinx.coroutines.CoroutineScope
|
||
import kotlinx.coroutines.Dispatchers
|
||
import kotlinx.coroutines.Job
|
||
import kotlinx.coroutines.SupervisorJob
|
||
import kotlinx.coroutines.cancel
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.launch
|
||
import java.text.SimpleDateFormat
|
||
import java.util.Date
|
||
import java.util.Locale
|
||
|
||
class SleepTrackingService : Service() {
|
||
|
||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||
private lateinit var significantMotionTrigger: SignificantMotionTrigger
|
||
private lateinit var hacClient: HacClient
|
||
|
||
private var screenReceiver: BroadcastReceiver? = null
|
||
private var inactivityJob: Job? = null
|
||
private var sleepApiPendingIntent: PendingIntent? = null
|
||
|
||
private var currentState: SleepState = SleepState.AWAKE
|
||
private var stateChangeTimestamp: Long = System.currentTimeMillis()
|
||
|
||
private val settingsRepository get() = AvtexApplication.instance.settingsRepository
|
||
|
||
override fun onCreate() {
|
||
super.onCreate()
|
||
Log.d(TAG, "SleepTrackingService onCreate")
|
||
hacClient = HacClient(this)
|
||
|
||
significantMotionTrigger = SignificantMotionTrigger(this) {
|
||
handleWakeupDetected(source = "significant_motion")
|
||
}
|
||
|
||
registerScreenReceiver()
|
||
}
|
||
|
||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||
val action = intent?.action ?: ACTION_START_TRACKING
|
||
Log.d(TAG, "onStartCommand action: $action")
|
||
|
||
when (action) {
|
||
ACTION_START_TRACKING -> {
|
||
startForegroundServiceWithNotification()
|
||
initializeTracking()
|
||
}
|
||
ACTION_STOP_TRACKING -> {
|
||
stopTracking()
|
||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||
stopSelf()
|
||
}
|
||
ACTION_MANUAL_TOGGLE -> {
|
||
val newState = if (currentState == SleepState.AWAKE) SleepState.ASLEEP else SleepState.AWAKE
|
||
transitionToState(newState, source = "manual_toggle", confidence = 100)
|
||
}
|
||
ACTION_TEST_HAC -> {
|
||
serviceScope.launch {
|
||
val settings = settingsRepository.getSettings()
|
||
val isAsleep = currentState == SleepState.ASLEEP
|
||
settingsRepository.addLog("HAC_TEST", "Отправка тестового запроса в Home Assistant...")
|
||
val result = hacClient.sendSleepEvent(settings, isAsleep, source = "manual_test")
|
||
result.onSuccess { msg ->
|
||
settingsRepository.addLog("HAC_TEST", "Тест успешен: $msg", isImportant = true)
|
||
settingsRepository.updateHacStatus("Тест успешен: $msg")
|
||
}.onFailure { err ->
|
||
settingsRepository.addLog("HAC_TEST", "Ошибка теста: ${err.localizedMessage}", isImportant = true)
|
||
settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return START_STICKY
|
||
}
|
||
|
||
private fun startForegroundServiceWithNotification() {
|
||
val notification = buildNotification(
|
||
title = "Avtex: Мониторинг активности",
|
||
text = "Статус: ${if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️"}"
|
||
)
|
||
startForeground(NOTIFICATION_ID, notification)
|
||
}
|
||
|
||
private fun initializeTracking() {
|
||
serviceScope.launch {
|
||
val settings = settingsRepository.getSettings()
|
||
currentState = if (settings.currentState == "ASLEEP") SleepState.ASLEEP else SleepState.AWAKE
|
||
stateChangeTimestamp = settings.lastStateChangeMillis
|
||
settingsRepository.updateTrackingActive(true)
|
||
settingsRepository.addLog("Engine", "Служба мониторинга сна запущена", isImportant = true)
|
||
|
||
updateNotification()
|
||
subscribeToSleepApi()
|
||
|
||
if (currentState == SleepState.ASLEEP) {
|
||
significantMotionTrigger.arm()
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun stopTracking() {
|
||
serviceScope.launch {
|
||
settingsRepository.updateTrackingActive(false)
|
||
settingsRepository.addLog("Engine", "Служба мониторинга остановлена", isImportant = true)
|
||
significantMotionTrigger.disarm()
|
||
unsubscribeFromSleepApi()
|
||
inactivityJob?.cancel()
|
||
}
|
||
}
|
||
|
||
private fun registerScreenReceiver() {
|
||
screenReceiver = object : BroadcastReceiver() {
|
||
override fun onReceive(context: Context, intent: Intent) {
|
||
when (intent.action) {
|
||
Intent.ACTION_SCREEN_OFF -> {
|
||
Log.d(TAG, "Screen OFF event received")
|
||
handleScreenOff()
|
||
}
|
||
Intent.ACTION_SCREEN_ON -> {
|
||
Log.d(TAG, "Screen ON event received")
|
||
}
|
||
Intent.ACTION_USER_PRESENT -> {
|
||
Log.d(TAG, "User unlocked device")
|
||
handleWakeupDetected(source = "screen_unlocked")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
val filter = IntentFilter().apply {
|
||
addAction(Intent.ACTION_SCREEN_OFF)
|
||
addAction(Intent.ACTION_SCREEN_ON)
|
||
addAction(Intent.ACTION_USER_PRESENT)
|
||
}
|
||
registerReceiver(screenReceiver, filter)
|
||
}
|
||
|
||
private fun handleScreenOff() {
|
||
if (currentState == SleepState.AWAKE) {
|
||
inactivityJob?.cancel()
|
||
inactivityJob = serviceScope.launch {
|
||
val settings = settingsRepository.getSettings()
|
||
val minutes = settings.inactivityMinutes
|
||
Log.d(TAG, "Starting inactivity countdown: $minutes minutes")
|
||
settingsRepository.addLog("Engine", "Экран выключен. Ожидание сна ($minutes мин)...")
|
||
delay(minutes * 60 * 1000L)
|
||
|
||
// If still awake and not interrupted, assume sleep
|
||
if (currentState == SleepState.AWAKE) {
|
||
settingsRepository.addLog("Engine", "Время покоя истекло ($minutes мин). Переход в режим сна.", isImportant = true)
|
||
transitionToState(SleepState.ASLEEP, source = "inactivity_timer", confidence = 90)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun handleWakeupDetected(source: String) {
|
||
inactivityJob?.cancel()
|
||
if (currentState == SleepState.ASLEEP) {
|
||
Log.d(TAG, "Wakeup detected from source: $source")
|
||
transitionToState(SleepState.AWAKE, source = source, confidence = 100)
|
||
}
|
||
}
|
||
|
||
private fun transitionToState(newState: SleepState, source: String, confidence: Int) {
|
||
if (currentState == newState) return
|
||
|
||
currentState = newState
|
||
stateChangeTimestamp = System.currentTimeMillis()
|
||
|
||
serviceScope.launch {
|
||
val isAsleep = (newState == SleepState.ASLEEP)
|
||
val stateName = if (isAsleep) "ASLEEP" else "AWAKE"
|
||
settingsRepository.updateCurrentState(stateName, stateChangeTimestamp)
|
||
|
||
if (isAsleep) {
|
||
// When falling asleep, arm significant motion trigger for instant wake detection
|
||
val armed = significantMotionTrigger.arm()
|
||
settingsRepository.addLog(
|
||
"Engine",
|
||
"Зафиксировано засыпание ($source). Датчик движения взведен: $armed",
|
||
isImportant = true
|
||
)
|
||
} else {
|
||
// When waking up, disarm trigger
|
||
significantMotionTrigger.disarm()
|
||
settingsRepository.addLog(
|
||
"Engine",
|
||
"Зафиксировано пробуждение ($source).",
|
||
isImportant = true
|
||
)
|
||
}
|
||
|
||
updateNotification()
|
||
|
||
// Acquire temporary WakeLock to ensure HTTP request reaches HAC on OnePlus
|
||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Avtex:HacTransmissionWakeLock")
|
||
wakeLock.acquire(15000L) // max 15 seconds
|
||
|
||
try {
|
||
val settings = settingsRepository.getSettings()
|
||
val result = hacClient.sendSleepEvent(settings, isAsleep, source, confidence)
|
||
result.onSuccess { msg ->
|
||
Log.d(TAG, "HAC send success: $msg")
|
||
settingsRepository.addLog("HAC", "Отправлено в Home Assistant: $msg")
|
||
settingsRepository.updateHacStatus("Отправлено в ${formatTime(stateChangeTimestamp)}: $msg")
|
||
}.onFailure { err ->
|
||
Log.e(TAG, "HAC send error", err)
|
||
settingsRepository.addLog("HAC", "Ошибка отправки в Home Assistant: ${err.localizedMessage}", isImportant = true)
|
||
settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}")
|
||
}
|
||
} finally {
|
||
if (wakeLock.isHeld) {
|
||
wakeLock.release()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun subscribeToSleepApi() {
|
||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
|
||
Log.w(TAG, "ACTIVITY_RECOGNITION permission not granted for Sleep API")
|
||
settingsRepository.addLog("SleepAPI", "Нет разрешения на активность. Используются аппаратные датчики движения.")
|
||
return
|
||
}
|
||
|
||
try {
|
||
val intent = Intent(this, SleepReceiver::class.java).apply {
|
||
action = SleepReceiver.ACTION_SLEEP_API_EVENT
|
||
}
|
||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||
} else {
|
||
PendingIntent.FLAG_UPDATE_CURRENT
|
||
}
|
||
sleepApiPendingIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_SLEEP_API, intent, flags)
|
||
|
||
val request = SleepSegmentRequest.getDefaultSleepSegmentRequest()
|
||
val task = ActivityRecognition.getClient(this).requestSleepSegmentUpdates(sleepApiPendingIntent!!, request)
|
||
task.addOnSuccessListener {
|
||
Log.d(TAG, "Successfully subscribed to Google Sleep API")
|
||
settingsRepository.addLog("SleepAPI", "Google Sleep API успешно подключен")
|
||
}
|
||
task.addOnFailureListener { e ->
|
||
Log.e(TAG, "Failed to subscribe to Google Sleep API", e)
|
||
settingsRepository.addLog("SleepAPI", "Не удалось подключить Google Sleep API: ${e.localizedMessage}")
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Exception subscribing to Sleep API", e)
|
||
}
|
||
}
|
||
|
||
private fun unsubscribeFromSleepApi() {
|
||
sleepApiPendingIntent?.let { pi ->
|
||
try {
|
||
ActivityRecognition.getClient(this).removeSleepSegmentUpdates(pi)
|
||
Log.d(TAG, "Unsubscribed from Sleep API")
|
||
} catch (e: Exception) {
|
||
Log.e(TAG, "Error unsubscribing from Sleep API", e)
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun buildNotification(title: String, text: String): Notification {
|
||
val openAppIntent = Intent(this, MainActivity::class.java).apply {
|
||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||
}
|
||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||
} else {
|
||
PendingIntent.FLAG_UPDATE_CURRENT
|
||
}
|
||
val pendingIntent = PendingIntent.getActivity(this, 0, openAppIntent, flags)
|
||
|
||
return NotificationCompat.Builder(this, AvtexApplication.CHANNEL_ID_TRACKING)
|
||
.setContentTitle(title)
|
||
.setContentText(text)
|
||
.setSmallIcon(android.R.drawable.ic_lock_idle_charging)
|
||
.setContentIntent(pendingIntent)
|
||
.setOngoing(true)
|
||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||
.build()
|
||
}
|
||
|
||
private fun updateNotification() {
|
||
val stateText = if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️"
|
||
val timeFormatted = formatTime(stateChangeTimestamp)
|
||
val notification = buildNotification(
|
||
title = "Avtex: $stateText",
|
||
text = "Состояние с $timeFormatted"
|
||
)
|
||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||
}
|
||
|
||
private fun formatTime(millis: Long): String {
|
||
return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(millis))
|
||
}
|
||
|
||
override fun onDestroy() {
|
||
Log.d(TAG, "SleepTrackingService onDestroy")
|
||
screenReceiver?.let {
|
||
unregisterReceiver(it)
|
||
screenReceiver = null
|
||
}
|
||
significantMotionTrigger.disarm()
|
||
unsubscribeFromSleepApi()
|
||
serviceScope.cancel()
|
||
super.onDestroy()
|
||
}
|
||
|
||
override fun onBind(intent: Intent?): IBinder? = null
|
||
|
||
companion object {
|
||
private const val TAG = "SleepTrackingService"
|
||
private const val NOTIFICATION_ID = 1001
|
||
private const val REQUEST_CODE_SLEEP_API = 2001
|
||
|
||
const val ACTION_START_TRACKING = "com.example.avtex.ACTION_START_TRACKING"
|
||
const val ACTION_STOP_TRACKING = "com.example.avtex.ACTION_STOP_TRACKING"
|
||
const val ACTION_MANUAL_TOGGLE = "com.example.avtex.ACTION_MANUAL_TOGGLE"
|
||
const val ACTION_TEST_HAC = "com.example.avtex.ACTION_TEST_HAC"
|
||
|
||
@Volatile
|
||
private var instance: SleepTrackingService? = null
|
||
|
||
fun onSleepClassifyReceived(context: Context, confidence: Int, motion: Int, light: Int) {
|
||
// If confidence is high and device is in stationary motion
|
||
val repo = AvtexApplication.instance.settingsRepository
|
||
CoroutineScope(Dispatchers.Default).launch {
|
||
val settings = repo.getSettings()
|
||
if (settings.isTrackingActive && settings.currentState == "AWAKE") {
|
||
if (confidence >= settings.confidenceThreshold && motion <= 2) {
|
||
Log.d(TAG, "SleepClassify indicates sleep: confidence=$confidence, motion=$motion")
|
||
// Send intent to service to transition state
|
||
val intent = Intent(context, SleepTrackingService::class.java).apply {
|
||
action = ACTION_MANUAL_TOGGLE // or specific intent
|
||
}
|
||
// Only trigger if actually confident
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fun onSleepSegmentReceived(context: Context, start: Long, end: Long, status: Int) {
|
||
Log.d(TAG, "SleepSegment completed: start=$start, end=$end, status=$status")
|
||
}
|
||
}
|
||
}
|