|
| 1 | +package com.evsuite.chargepilot |
| 2 | + |
| 3 | +import android.Manifest |
| 4 | +import android.annotation.SuppressLint |
| 5 | +import android.app.NotificationChannel |
| 6 | +import android.app.NotificationManager |
| 7 | +import android.app.PendingIntent |
| 8 | +import android.content.Context |
| 9 | +import android.content.Intent |
| 10 | +import android.content.pm.PackageManager |
| 11 | +import android.os.Build |
| 12 | +import androidx.core.app.NotificationCompat |
| 13 | +import androidx.core.app.NotificationManagerCompat |
| 14 | +import androidx.core.content.ContextCompat |
| 15 | +import com.evsuite.hardware.AppLogger |
| 16 | +import com.evsuite.hardware.telemetry.BatteryDigest |
| 17 | +import com.evsuite.hardware.telemetry.BatteryDigestStore |
| 18 | +import com.evsuite.hardware.telemetry.CalibrationVerdict |
| 19 | +import com.evsuite.hardware.telemetry.DigestChange |
| 20 | +import com.evsuite.hardware.telemetry.EnergySnapshot |
| 21 | +import java.io.File |
| 22 | +import java.time.Instant |
| 23 | +import java.time.ZoneId |
| 24 | + |
| 25 | +/** |
| 26 | + * The battery companion's daily round and its one way of speaking up unasked (CP-087). |
| 27 | + * |
| 28 | + * **Once a day.** At the first sample of a calendar day the four ledger analyses are recomputed, |
| 29 | + * in both flavors, and the day is written to [BatteryDigestStore]. What moved past its own band |
| 30 | + * since the day before becomes advice. A process that restarts during the day rewrites the day |
| 31 | + * and says nothing again. |
| 32 | + * |
| 33 | + * **Parked only.** Advice goes to the [AdviceJournal] at once; the notification waits for a car |
| 34 | + * that reads stopped, fails closed on an unreadable speed, and posts at most one per kind a day. |
| 35 | + * A moving car gets nothing here: driving advice stays with the voice (CP-083). |
| 36 | + * |
| 37 | + * Runs on the sampler thread, which owns the ledger it reads. |
| 38 | + */ |
| 39 | +internal class BatteryAdvisor( |
| 40 | + private val context: Context, |
| 41 | + /** The app's own language: a service keeps the head unit's otherwise (CP-077). */ |
| 42 | + private val text: () -> Context, |
| 43 | + private val zone: ZoneId = ZoneId.systemDefault(), |
| 44 | +) { |
| 45 | + private val filesDir: File = context.filesDir |
| 46 | + private val journal = AdviceJournal.of(filesDir) |
| 47 | + private val digests = BatteryDigestStore(File(filesDir, BatteryDigestStore.FILE_NAME)) |
| 48 | + private var digestDay: String? = null |
| 49 | + private var pending = true |
| 50 | + |
| 51 | + fun onSample(value: EnergySnapshot) { |
| 52 | + val day = Instant.ofEpochMilli(value.timestampMs).atZone(zone).toLocalDate().toString() |
| 53 | + if (day != digestDay) { |
| 54 | + digestDay = day |
| 55 | + runCatching { digest(day, value.timestampMs) } |
| 56 | + .onFailure { AppLogger.w(TAG, "battery digest failed: ${it.message}") } |
| 57 | + } |
| 58 | + if (!pending) return |
| 59 | + val gate = ParkedDeletionPolicy.gate(value.speedKmh, value.timestampMs, System.currentTimeMillis()) |
| 60 | + if (gate != ParkedDeletionGate.PARKED) return |
| 61 | + runCatching { flush(value.timestampMs) } |
| 62 | + .onFailure { AppLogger.w(TAG, "advice notification failed: ${it.message}") } |
| 63 | + } |
| 64 | + |
| 65 | + /** Anything the app says — the trip-end line included — is kept, then notified when parked. */ |
| 66 | + fun record(kind: AdviceKind, line: String, atMs: Long) { |
| 67 | + if (journal.append(AdviceEntry(atMs, kind, line))) pending = true |
| 68 | + } |
| 69 | + |
| 70 | + private fun digest(day: String, nowMs: Long) { |
| 71 | + val settings = VehicleSettings.read(context) |
| 72 | + val learned = LocalBatteryLearning.refresh(filesDir, settings.usableCapacityKwhWhenNew, nowMs) |
| 73 | + val today = BatteryDigest.of(day, learned.health, learned.drift, learned.exposure, learned.charge) |
| 74 | + val alreadyToday = digests.read().lastOrNull()?.day == day |
| 75 | + val previous = digests.upsert(today) |
| 76 | + if (alreadyToday) return |
| 77 | + BatteryDigest.changes(previous, today).forEach { record(AdviceKind.BATTERY, describe(it), nowMs) } |
| 78 | + } |
| 79 | + |
| 80 | + private fun describe(change: DigestChange): String { |
| 81 | + val res = text() |
| 82 | + return when (change) { |
| 83 | + is DigestChange.HealthMoved -> res.getString( |
| 84 | + R.string.advice_health_moved, change.toPercent, change.bandPercent, change.fromPercent, |
| 85 | + ) |
| 86 | + is DigestChange.HealthBandNarrowed -> { |
| 87 | + val from = change.fromBandPercent |
| 88 | + if (from == null) { |
| 89 | + res.getString(R.string.advice_health_first, change.healthPercent, change.toBandPercent) |
| 90 | + } else { |
| 91 | + res.getString( |
| 92 | + R.string.advice_health_narrowed, change.healthPercent, change.toBandPercent, from, |
| 93 | + ) |
| 94 | + } |
| 95 | + } |
| 96 | + is DigestChange.CalibrationChanged -> { |
| 97 | + val state = res.getString( |
| 98 | + when (change.to) { |
| 99 | + CalibrationVerdict.SETTLED -> R.string.battery_health_calibration_state_settled |
| 100 | + CalibrationVerdict.WATCH -> R.string.battery_health_calibration_state_watch |
| 101 | + CalibrationVerdict.RE_ANCHOR_SUGGESTED -> R.string.battery_health_calibration_state_re_anchor |
| 102 | + }, |
| 103 | + ) |
| 104 | + listOfNotNull( |
| 105 | + res.getString(R.string.advice_calibration, state), |
| 106 | + change.daysSinceFullCharge?.let { |
| 107 | + res.getString(R.string.battery_health_calibration_last_full, it) |
| 108 | + }, |
| 109 | + if (change.to == CalibrationVerdict.RE_ANCHOR_SUGGESTED) { |
| 110 | + res.getString(R.string.battery_health_calibration_re_anchor) |
| 111 | + } else null, |
| 112 | + ).joinToString(" ") |
| 113 | + } |
| 114 | + } |
| 115 | + } |
| 116 | + |
| 117 | + private fun flush(nowMs: Long) { |
| 118 | + pending = false |
| 119 | + if (!notificationsEnabled(context)) return |
| 120 | + val manager = NotificationManagerCompat.from(context) |
| 121 | + if (!manager.areNotificationsEnabled()) return |
| 122 | + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && |
| 123 | + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != |
| 124 | + PackageManager.PERMISSION_GRANTED |
| 125 | + ) return |
| 126 | + val today = Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() |
| 127 | + val entries = journal.read() |
| 128 | + val ofToday = entries.filter { |
| 129 | + Instant.ofEpochMilli(it.atMs).atZone(zone).toLocalDate() == today |
| 130 | + } |
| 131 | + val waiting = ofToday.filter { !it.notified } |
| 132 | + if (waiting.isEmpty()) return |
| 133 | + val spent = ofToday.filter { it.notified }.map { it.kind }.toSet() |
| 134 | + val posted = waiting.groupBy { it.kind } |
| 135 | + .filterKeys { it !in spent } |
| 136 | + .map { (kind, list) -> post(manager, kind, list.last()); list.last() } |
| 137 | + // What the cap held back is still in the history; it is not queued for tomorrow. |
| 138 | + journal.markNotified(waiting.filter { entry -> posted.any { it.kind == entry.kind } } + posted) |
| 139 | + } |
| 140 | + |
| 141 | + // The head unit runs API 28, where no permission gates a notification. POST_NOTIFICATIONS is |
| 142 | + // deliberately not declared (see the manifest); on API 33+ flush() finds it missing and posts |
| 143 | + // nothing, and the advice stays in the history. |
| 144 | + @SuppressLint("MissingPermission", "NotificationPermission") |
| 145 | + private fun post(manager: NotificationManagerCompat, kind: AdviceKind, entry: AdviceEntry) { |
| 146 | + val res = text() |
| 147 | + createChannel(res) |
| 148 | + val intent = Intent(context, AdviceHistoryActivity::class.java) |
| 149 | + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) |
| 150 | + val tap = PendingIntent.getActivity( |
| 151 | + context, kind.ordinal, intent, |
| 152 | + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, |
| 153 | + ) |
| 154 | + val notification = NotificationCompat.Builder(context, CHANNEL_ID) |
| 155 | + .setSmallIcon(R.drawable.ic_launcher_monochrome) |
| 156 | + .setContentTitle(res.getString(R.string.advice_notification_title)) |
| 157 | + .setContentText(entry.text) |
| 158 | + .setStyle(NotificationCompat.BigTextStyle().bigText(entry.text)) |
| 159 | + .setContentIntent(tap) |
| 160 | + .setAutoCancel(true) |
| 161 | + .build() |
| 162 | + manager.notify(NOTIFICATION_BASE_ID + kind.ordinal, notification) |
| 163 | + } |
| 164 | + |
| 165 | + private fun createChannel(res: Context) { |
| 166 | + val manager = context.getSystemService(NotificationManager::class.java) ?: return |
| 167 | + manager.createNotificationChannel( |
| 168 | + NotificationChannel( |
| 169 | + CHANNEL_ID, |
| 170 | + res.getString(R.string.advice_channel), |
| 171 | + NotificationManager.IMPORTANCE_DEFAULT, |
| 172 | + ).apply { description = res.getString(R.string.advice_channel_description) }, |
| 173 | + ) |
| 174 | + } |
| 175 | + |
| 176 | + companion object { |
| 177 | + private const val TAG = "EVChargePilot" |
| 178 | + private const val CHANNEL_ID = "battery_advice" |
| 179 | + private const val NOTIFICATION_BASE_ID = 100 |
| 180 | + private const val PREFERENCES = "battery_advice" |
| 181 | + private const val PREF_NOTIFY = "notifications_enabled" |
| 182 | + |
| 183 | + fun notificationsEnabled(context: Context): Boolean = context.applicationContext |
| 184 | + .getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).getBoolean(PREF_NOTIFY, true) |
| 185 | + |
| 186 | + fun storeNotificationsEnabled(context: Context, enabled: Boolean) = context.applicationContext |
| 187 | + .getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE).edit() |
| 188 | + .putBoolean(PREF_NOTIFY, enabled).apply() |
| 189 | + } |
| 190 | +} |
0 commit comments