From 0f3cc72e2eede08624e82649de980991c7b66f4d Mon Sep 17 00:00:00 2001 From: keepalivedev Date: Sun, 19 Apr 2026 12:24:59 -0500 Subject: [PATCH 01/26] refactor to make testing easier --- .../io/keepalive/android/AlertIntentAction.kt | 58 +++++++++++++++++ .../java/io/keepalive/android/AlertService.kt | 23 ++----- .../io/keepalive/android/SmsAlertSender.kt | 8 ++- .../android/receivers/AlarmReceiver.kt | 65 +++++++------------ .../android/receivers/AlarmStageRule.kt | 27 ++++++++ 5 files changed, 119 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/io/keepalive/android/AlertIntentAction.kt create mode 100644 app/src/main/java/io/keepalive/android/receivers/AlarmStageRule.kt diff --git a/app/src/main/java/io/keepalive/android/AlertIntentAction.kt b/app/src/main/java/io/keepalive/android/AlertIntentAction.kt new file mode 100644 index 0000000..f22352a --- /dev/null +++ b/app/src/main/java/io/keepalive/android/AlertIntentAction.kt @@ -0,0 +1,58 @@ +package io.keepalive.android + +/** + * Outcome of the idempotent dedup guard in [AlertService.onStartCommand]. + * + * Pulled out as a pure function so it can be unit-tested without spinning up + * a Service or Robolectric. The mapping: + * + * - New trigger (or fresh state) → [NewAlert] + * - Same trigger, all steps done → [Skip] + * - Same trigger, some steps still pending → [Resume] + * - Older trigger than saved → [Skip] + */ +sealed class AlertIntentAction { + /** Brand-new alert cycle: initialize the step tracker and run all steps. */ + object NewAlert : AlertIntentAction() + + /** Same trigger as a partially-completed cycle: re-run only the pending steps. */ + object Resume : AlertIntentAction() + + /** Duplicate/redelivery/stale — do nothing, stop the service. */ + object Skip : AlertIntentAction() +} + +/** + * Decide what to do with an incoming alert intent given the persisted + * step-tracker state. + * + * @param triggerTimestamp timestamp on the incoming intent + * @param savedTrigger the last trigger timestamp persisted (0 if none) + * @param savedSteps the last persisted completed-step bitmask + * @param allStepsCompleteMask bitmask value meaning "every step is done" + */ +internal fun decideAlertIntentAction( + triggerTimestamp: Long, + savedTrigger: Long, + savedSteps: Int, + allStepsCompleteMask: Int +): AlertIntentAction { + // No trigger on the intent — treat as a fresh alert. (Callers typically + // guard against this earlier, but the fallback matches the pre-refactor + // behavior of reusing the existing tracker.) + if (triggerTimestamp <= 0L) return AlertIntentAction.NewAlert + + return when { + triggerTimestamp == savedTrigger && + (savedSteps and allStepsCompleteMask) == allStepsCompleteMask -> + AlertIntentAction.Skip + + triggerTimestamp < savedTrigger -> + AlertIntentAction.Skip + + triggerTimestamp == savedTrigger -> + AlertIntentAction.Resume + + else -> AlertIntentAction.NewAlert + } +} diff --git a/app/src/main/java/io/keepalive/android/AlertService.kt b/app/src/main/java/io/keepalive/android/AlertService.kt index 4338784..1a98ce1 100644 --- a/app/src/main/java/io/keepalive/android/AlertService.kt +++ b/app/src/main/java/io/keepalive/android/AlertService.kt @@ -130,31 +130,20 @@ class AlertService : Service() { val savedTrigger = prefs.getLong(PREF_ALERT_TRIGGER_TIMESTAMP, 0L) val savedSteps = prefs.getInt(PREF_ALERT_STEPS_COMPLETED, 0) - when { - // All steps already done for this trigger → skip - triggerTimestamp == savedTrigger && - savedSteps and ALL_STEPS_COMPLETE == ALL_STEPS_COMPLETE -> { + when (decideAlertIntentAction(triggerTimestamp, savedTrigger, savedSteps, ALL_STEPS_COMPLETE)) { + AlertIntentAction.Skip -> { DebugLogger.d("AlertService", - "All alert steps already complete for trigger=$triggerTimestamp, skipping") + "Skipping alert intent trigger=$triggerTimestamp " + + "(savedTrigger=$savedTrigger, savedSteps=$savedSteps)") stopService() return START_REDELIVER_INTENT } - // An even newer alert was already started → skip the stale intent - triggerTimestamp < savedTrigger -> { - DebugLogger.d("AlertService", - "Skipping stale trigger=$triggerTimestamp " + - "(newer=$savedTrigger exists)") - stopService() - return START_REDELIVER_INTENT - } - // Same trigger, some steps remaining → resume - triggerTimestamp == savedTrigger -> { + AlertIntentAction.Resume -> { DebugLogger.d("AlertService", "Resuming alert trigger=$triggerTimestamp " + "(completedSteps=$savedSteps)") } - // Brand-new trigger → initialize the step tracker - else -> { + AlertIntentAction.NewAlert -> { prefs.edit(commit = true) { putLong(PREF_ALERT_TRIGGER_TIMESTAMP, triggerTimestamp) putInt(PREF_ALERT_STEPS_COMPLETED, 0) diff --git a/app/src/main/java/io/keepalive/android/SmsAlertSender.kt b/app/src/main/java/io/keepalive/android/SmsAlertSender.kt index 6aefda3..8ae21d1 100644 --- a/app/src/main/java/io/keepalive/android/SmsAlertSender.kt +++ b/app/src/main/java/io/keepalive/android/SmsAlertSender.kt @@ -90,7 +90,12 @@ fun getSMSManager(context: Context): SmsManager? { } -class AlertMessageSender(private val context: Context) { +class AlertMessageSender @JvmOverloads constructor( + private val context: Context, + // Injectable for tests. Default resolves the real SmsManager so production + // callers don't need to care. Pass null or a mock from unit tests. + private val smsManager: SmsManager? = getSMSManager(context) +) { companion object { // How long to wait before forcing the SMS_SENT receiver to unregister. @@ -100,7 +105,6 @@ class AlertMessageSender(private val context: Context) { private const val SMS_RECEIVER_SAFETY_TIMEOUT_MS = 2 * 60 * 1000L } - private val smsManager = getSMSManager(context) private val prefs = getEncryptedSharedPreferences(context) private val alertNotificationHelper = AlertNotificationHelper(context) private val smsContacts: MutableList = loadJSONSharedPreference(prefs, diff --git a/app/src/main/java/io/keepalive/android/receivers/AlarmReceiver.kt b/app/src/main/java/io/keepalive/android/receivers/AlarmReceiver.kt index 0d061f0..00091a3 100644 --- a/app/src/main/java/io/keepalive/android/receivers/AlarmReceiver.kt +++ b/app/src/main/java/io/keepalive/android/receivers/AlarmReceiver.kt @@ -67,50 +67,29 @@ class AlarmReceiver : BroadcastReceiver() { } private fun getAlarmStage(context: Context, intent: Intent): String { - var alarmStage = "" - - // the current alarm stage should be passed in as an extra - intent.extras?.let { - - alarmStage = it.getString("AlarmStage", "periodic") - - // also check when the alarm was supposed to go off and compare to - // when it actually did go off - val alarmTimestamp = it.getLong("AlarmTimestamp", 0) - - // this is just for informational purposes so we can see how well Android respects - // the alarm time we set... - if (alarmTimestamp != 0L) { - - val alarmDtStr = getDateTimeStrFromTimestamp(alarmTimestamp) - val currentDtStr = getDateTimeStrFromTimestamp(System.currentTimeMillis()) - val delaySeconds = (System.currentTimeMillis() - alarmTimestamp) / 1000 - - DebugLogger.d(tag, context.getString(R.string.debug_log_alarm_time_comparison, currentDtStr, alarmDtStr, delaySeconds)) - - // todo ensure that there are no edge cases where this behavior would prevent - // a real alert from firing when it should - // If a "final" alarm fires significantly later than scheduled, it's likely - // stale — e.g., from an app update/redeploy, extended device-off, or process - // death. Downgrade to "periodic" to give the user a fresh "Are you there?" - // prompt instead of immediately sending the real alert. - // Threshold: if the delay exceeds the followup period, treat it as stale. - if (alarmStage == "final" && delaySeconds > 0) { - val prefs = getEncryptedSharedPreferences(context) - val followupMinutes = prefs.getString("followup_time_period_minutes", "60")?.toIntOrNull() ?: 60 - - val maxAcceptableDelaySeconds = followupMinutes * 60L - - // if the delay is longer than the followup then it means that the - // final alarm time would be in the past? - if (delaySeconds > maxAcceptableDelaySeconds) { - DebugLogger.d(tag, context.getString(R.string.debug_log_final_alarm_stale, delaySeconds, maxAcceptableDelaySeconds)) - alarmStage = "periodic" - } - } - } + val extras = intent.extras ?: return "periodic" + val declaredStage = extras.getString("AlarmStage", "periodic") + val alarmTimestamp = extras.getLong("AlarmTimestamp", 0) + + if (alarmTimestamp == 0L) return declaredStage + + // this is just for informational purposes so we can see how well Android respects + // the alarm time we set... + val alarmDtStr = getDateTimeStrFromTimestamp(alarmTimestamp) + val currentDtStr = getDateTimeStrFromTimestamp(System.currentTimeMillis()) + val delaySeconds = (System.currentTimeMillis() - alarmTimestamp) / 1000 + + DebugLogger.d(tag, context.getString(R.string.debug_log_alarm_time_comparison, currentDtStr, alarmDtStr, delaySeconds)) + + val followupMinutes = getEncryptedSharedPreferences(context) + .getString("followup_time_period_minutes", "60") + ?.toIntOrNull() ?: 60 + + val effectiveStage = computeEffectiveAlarmStage(declaredStage, delaySeconds, followupMinutes) + if (effectiveStage != declaredStage) { + DebugLogger.d(tag, context.getString(R.string.debug_log_final_alarm_stale, delaySeconds, followupMinutes * 60L)) } - return alarmStage + return effectiveStage } class AppForegroundChecker(private val context: Context) { diff --git a/app/src/main/java/io/keepalive/android/receivers/AlarmStageRule.kt b/app/src/main/java/io/keepalive/android/receivers/AlarmStageRule.kt new file mode 100644 index 0000000..2bdbacb --- /dev/null +++ b/app/src/main/java/io/keepalive/android/receivers/AlarmStageRule.kt @@ -0,0 +1,27 @@ +package io.keepalive.android.receivers + +/** + * Pure rule used by [AlarmReceiver] to decide the effective alarm stage + * given the declared stage and how late the alarm fired. + * + * If a "final" alarm fires significantly later than scheduled, it's likely + * stale — e.g., from an app update/redeploy, extended device-off, or process + * death. In that case we downgrade to "periodic" so the user gets a fresh + * "Are you there?" prompt instead of an immediate real alert. + * + * Threshold: delay greater than the follow-up period means the would-be + * final-alarm fire time is now in the past. + * + * Pulled out of [AlarmReceiver] so the rule can be unit-tested without a + * Context or a clock. + */ +internal fun computeEffectiveAlarmStage( + declaredStage: String, + delaySeconds: Long, + followupMinutes: Int +): String { + if (declaredStage != "final") return declaredStage + if (delaySeconds <= 0) return declaredStage + val maxAcceptableDelaySeconds = followupMinutes * 60L + return if (delaySeconds > maxAcceptableDelaySeconds) "periodic" else declaredStage +} From 53fd2def1c6434a48dbae91cdbdd5f34a52092d5 Mon Sep 17 00:00:00 2001 From: keepalivedev Date: Sun, 19 Apr 2026 12:27:38 -0500 Subject: [PATCH 02/26] add testing dependencies --- app/build.gradle | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/build.gradle b/app/build.gradle index 0008971..adbe5d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,6 +58,13 @@ android { viewBinding = true buildConfig = true } + // Let unit tests load Android resources / use Robolectric manifest + testOptions { + unitTests { + includeAndroidResources = true + returnDefaultValues = true + } + } namespace = 'io.keepalive.android' flavorDimensions += "platform" @@ -131,6 +138,19 @@ dependencies { testImplementation 'org.mockito:mockito-core:5.23.0' testImplementation 'junit:junit:4.13.2' + // Robolectric 4.16 for running Android code on the JVM (SDK 36 supported). + testImplementation 'org.robolectric:robolectric:4.16.1' + testImplementation 'androidx.test:core:1.7.0' + testImplementation 'androidx.test:core-ktx:1.7.0' + testImplementation 'androidx.test.ext:junit:1.3.0' + testImplementation 'androidx.test.ext:junit-ktx:1.3.0' + + // MockK — works with Kotlin 2.x `object`s and top-level funs better than Mockito. + testImplementation 'io.mockk:mockk:1.14.9' + + // Coroutine test dispatchers (future-proofing; some helpers already use Handler). + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2' + // android instrumented test dependencies androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' From 58a8f6dc430519fd5aea5afa71823fd48ae2df79 Mon Sep 17 00:00:00 2001 From: keepalivedev Date: Sun, 19 Apr 2026 12:30:23 -0500 Subject: [PATCH 03/26] add tests --- .../android/AcknowledgeAreYouThereTest.kt | 123 ++++++++ .../android/AlertIntentActionTest.kt | 102 +++++++ .../android/AlertMessageSenderTest.kt | 227 ++++++++++++++ .../android/DeviceActivityQueryTest.kt | 96 ++++++ .../io/keepalive/android/DoAlertCheckTest.kt | 286 ++++++++++++++++++ .../android/GetSavedReferenceTimestampTest.kt | 45 +++ .../keepalive/android/ToolchainSmokeTest.kt | 32 ++ .../android/receivers/AlarmReceiverTest.kt | 109 +++++++ .../android/receivers/AlarmStageRuleTest.kt | 57 ++++ .../receivers/BootBroadcastReceiverTest.kt | 138 +++++++++ .../android/receivers/SMSSentReceiverTest.kt | 88 ++++++ .../android/testing/FakeAlertCheckDeps.kt | 127 ++++++++ .../android/testing/FakeSharedPreferences.kt | 73 +++++ 13 files changed, 1503 insertions(+) create mode 100644 app/src/test/java/io/keepalive/android/AcknowledgeAreYouThereTest.kt create mode 100644 app/src/test/java/io/keepalive/android/AlertIntentActionTest.kt create mode 100644 app/src/test/java/io/keepalive/android/AlertMessageSenderTest.kt create mode 100644 app/src/test/java/io/keepalive/android/DeviceActivityQueryTest.kt create mode 100644 app/src/test/java/io/keepalive/android/DoAlertCheckTest.kt create mode 100644 app/src/test/java/io/keepalive/android/GetSavedReferenceTimestampTest.kt create mode 100644 app/src/test/java/io/keepalive/android/ToolchainSmokeTest.kt create mode 100644 app/src/test/java/io/keepalive/android/receivers/AlarmReceiverTest.kt create mode 100644 app/src/test/java/io/keepalive/android/receivers/AlarmStageRuleTest.kt create mode 100644 app/src/test/java/io/keepalive/android/receivers/BootBroadcastReceiverTest.kt create mode 100644 app/src/test/java/io/keepalive/android/receivers/SMSSentReceiverTest.kt create mode 100644 app/src/test/java/io/keepalive/android/testing/FakeAlertCheckDeps.kt create mode 100644 app/src/test/java/io/keepalive/android/testing/FakeSharedPreferences.kt diff --git a/app/src/test/java/io/keepalive/android/AcknowledgeAreYouThereTest.kt b/app/src/test/java/io/keepalive/android/AcknowledgeAreYouThereTest.kt new file mode 100644 index 0000000..551036d --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AcknowledgeAreYouThereTest.kt @@ -0,0 +1,123 @@ +package io.keepalive.android + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests [AcknowledgeAreYouThere.acknowledge] — the single handler called when + * the user responds to the "Are you there?" prompt (I'm-OK button, notification + * tap, or BOOT_COMPLETED after Direct Boot). + * + * Correctness hinges on: the Direct Boot flag is cleared, the last-activity + * timestamp is updated (so a racing final alarm won't alert), and a fresh + * periodic alarm is scheduled (which replaces any pending final alarm). + */ +@RunWith(RobolectricTestRunner::class) +class AcknowledgeAreYouThereTest { + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + private val UTILITY_FUNCTIONS_KT = "io.keepalive.android.UtilityFunctionsKt" + + @Before fun setUp() { + mockkStatic(UTILITY_FUNCTIONS_KT) + // setAlarm hits AlarmManager — mock it out so we can just verify it was called. + every { setAlarm(any(), any(), any(), any(), any()) } returns Unit + // Seed prefs with the defaults the acknowledgement reads. + getEncryptedSharedPreferences(appCtx).edit() + .putString("time_period_hours", "12") + .commit() + // Simulate Direct Boot pending state we expect to clear. + getDeviceProtectedPreferences(appCtx).edit() + .putBoolean("direct_boot_notification_pending", true) + .commit() + } + + @After fun tearDown() { + unmockkStatic(UTILITY_FUNCTIONS_KT) + } + + @Test fun `clears the direct boot notification pending flag`() { + AcknowledgeAreYouThere.acknowledge(appCtx) + + assertFalse(getDeviceProtectedPreferences(appCtx) + .getBoolean("direct_boot_notification_pending", true)) + } + + @Test fun `writes last_activity_timestamp so a racing final alarm sees the user as active`() { + val before = System.currentTimeMillis() + + AcknowledgeAreYouThere.acknowledge(appCtx) + + val saved = getDeviceProtectedPreferences(appCtx) + .getLong("last_activity_timestamp", -1L) + assertTrue("timestamp should be recent (saved=$saved, before=$before)", + saved >= before) + } + + @Test fun `schedules a fresh periodic alarm`() { + val stageSlot = slot() + + AcknowledgeAreYouThere.acknowledge(appCtx) + + verify(exactly = 1) { + setAlarm( + eq(appCtx), + any(), + any(), + capture(stageSlot), + any() + ) + } + assertEquals("periodic", stageSlot.captured) + } + + @Test fun `uses the configured check period for the periodic alarm`() { + getEncryptedSharedPreferences(appCtx).edit() + .putString("time_period_hours", "4") + .commit() + val periodSlot = slot() + + AcknowledgeAreYouThere.acknowledge(appCtx) + + verify { setAlarm(any(), any(), capture(periodSlot), any(), any()) } + assertEquals(4 * 60, periodSlot.captured) + } + + @Test fun `falls back to 12h check period when preference is unparseable`() { + getEncryptedSharedPreferences(appCtx).edit() + .putString("time_period_hours", "not-a-number") + .commit() + val periodSlot = slot() + + AcknowledgeAreYouThere.acknowledge(appCtx) + + verify { setAlarm(any(), any(), capture(periodSlot), any(), any()) } + assertEquals(12 * 60, periodSlot.captured) + } + + @Test fun `acknowledgement can be called even when no flag was set`() { + // Fresh install / never posted prompt: flag is absent. Should be a no-op + // on the flag but still schedule periodic. + getDeviceProtectedPreferences(appCtx).edit() + .remove("direct_boot_notification_pending") + .commit() + + AcknowledgeAreYouThere.acknowledge(appCtx) + + verify(exactly = 1) { setAlarm(any(), any(), any(), eq("periodic"), any()) } + } +} diff --git a/app/src/test/java/io/keepalive/android/AlertIntentActionTest.kt b/app/src/test/java/io/keepalive/android/AlertIntentActionTest.kt new file mode 100644 index 0000000..639b9ce --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertIntentActionTest.kt @@ -0,0 +1,102 @@ +package io.keepalive.android + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Pure unit tests for the AlertService dedup rule. + * + * The alert intent carries a trigger timestamp; the service persists which + * steps have completed for that trigger. This rule decides what to do with + * each incoming intent — critical because the OS redelivers intents under + * START_REDELIVER_INTENT, and we must not duplicate-send or drop a + * legitimately newer alert. + */ +class AlertIntentActionTest { + + /** Matches AlertService.ALL_STEPS_COMPLETE — SMS|CALL|LOCATION|WEBHOOK = 1|2|4|8 = 15. */ + private val ALL = 1 or 2 or 4 or 8 + + @Test fun `empty state accepts a new trigger`() { + val action = decideAlertIntentAction( + triggerTimestamp = 1_000L, + savedTrigger = 0L, + savedSteps = 0, + allStepsCompleteMask = ALL + ) + assertEquals(AlertIntentAction.NewAlert, action) + } + + @Test fun `newer trigger replaces an older one`() { + val action = decideAlertIntentAction( + triggerTimestamp = 2_000L, + savedTrigger = 1_000L, + savedSteps = ALL, // older alert fully done + allStepsCompleteMask = ALL + ) + assertEquals(AlertIntentAction.NewAlert, action) + } + + @Test fun `same trigger with all steps complete is a duplicate and is skipped`() { + val action = decideAlertIntentAction( + triggerTimestamp = 1_000L, + savedTrigger = 1_000L, + savedSteps = ALL, + allStepsCompleteMask = ALL + ) + assertEquals(AlertIntentAction.Skip, action) + } + + @Test fun `same trigger with some steps missing is resumed`() { + val action = decideAlertIntentAction( + triggerTimestamp = 1_000L, + savedTrigger = 1_000L, + savedSteps = 1 or 2, // SMS + call done, location/webhook missing + allStepsCompleteMask = ALL + ) + assertEquals(AlertIntentAction.Resume, action) + } + + @Test fun `same trigger with no steps done yet is resumed`() { + // The tracker was initialized but nothing completed before the process + // died. OS redelivers — we should continue, not restart as "new". + val action = decideAlertIntentAction( + triggerTimestamp = 1_000L, + savedTrigger = 1_000L, + savedSteps = 0, + allStepsCompleteMask = ALL + ) + assertEquals(AlertIntentAction.Resume, action) + } + + @Test fun `stale trigger is skipped when a newer one was already started`() { + val action = decideAlertIntentAction( + triggerTimestamp = 500L, + savedTrigger = 1_000L, + savedSteps = 0, + allStepsCompleteMask = ALL + ) + assertEquals(AlertIntentAction.Skip, action) + } + + @Test fun `zero or negative trigger falls through to new alert`() { + // Defensive: shouldn't happen in practice — callers guard with triggerTimestamp > 0. + assertEquals(AlertIntentAction.NewAlert, + decideAlertIntentAction(0L, 1_000L, ALL, ALL)) + assertEquals(AlertIntentAction.NewAlert, + decideAlertIntentAction(-1L, 1_000L, ALL, ALL)) + } + + @Test fun `partial completion with non-default mask is resumed not skipped`() { + // Regression guard: if ALL_STEPS_COMPLETE ever changes, make sure the + // "complete" test is `savedSteps AND mask == mask`, not savedSteps == mask. + val newMask = 1 or 2 or 4 or 8 or 16 // imagine a 5th step was added + val action = decideAlertIntentAction( + triggerTimestamp = 1_000L, + savedTrigger = 1_000L, + savedSteps = 1 or 2 or 4 or 8, // old ALL without the new bit + allStepsCompleteMask = newMask + ) + assertEquals(AlertIntentAction.Resume, action) + } +} diff --git a/app/src/test/java/io/keepalive/android/AlertMessageSenderTest.kt b/app/src/test/java/io/keepalive/android/AlertMessageSenderTest.kt new file mode 100644 index 0000000..b37ac1c --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertMessageSenderTest.kt @@ -0,0 +1,227 @@ +package io.keepalive.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.content.IntentFilter +import android.telephony.SmsManager +import androidx.test.core.app.ApplicationProvider +import com.google.gson.Gson +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertSame +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for [AlertMessageSender.sendAlertMessage] batching + single-receiver + * registration. + * + * Regression guards against the pre-fix behavior where a new SMSSentReceiver + * was registered inside the per-contact loop, causing N receivers to fire on + * the first SMS_SENT broadcast and the remaining N-1 results to go unreported. + */ +@RunWith(RobolectricTestRunner::class) +class AlertMessageSenderTest { + + /** Records registerReceiver calls from AlertMessageSender. */ + private class RecordingContext(base: Context) : ContextWrapper(base) { + val registered = mutableListOf() + + override fun getApplicationContext(): Context = this + + override fun registerReceiver( + receiver: BroadcastReceiver?, + filter: IntentFilter?, + flags: Int + ): Intent? { + if (receiver != null) registered.add(receiver) + return null + } + + override fun registerReceiver( + receiver: BroadcastReceiver?, + filter: IntentFilter? + ): Intent? { + if (receiver != null) registered.add(receiver) + return null + } + + override fun unregisterReceiver(receiver: BroadcastReceiver?) { + // swallow — the safety-net postDelayed will hit this eventually; + // irrelevant for these tests + } + } + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + private val gson = Gson() + + @Before fun seedContacts() { + // Default: one enabled contact with a short single-part message. + seedContacts( + contact(phone = "+15551111111", msg = "help") + ) + } + + // ---- helpers ----------------------------------------------------------- + + private fun contact( + phone: String, + msg: String = "help", + enabled: Boolean = true, + includeLocation: Boolean = false + ) = SMSEmergencyContactSetting(phone, msg, enabled, includeLocation) + + private fun seedContacts(vararg contacts: SMSEmergencyContactSetting) { + val prefs = getEncryptedSharedPreferences(appCtx) + prefs.edit() + .putString("PHONE_NUMBER_SETTINGS", gson.toJson(contacts.toList())) + .commit() + } + + private fun mockSms( + partsBySource: Map> + ): SmsManager = mockk(relaxed = true) { + every { divideMessage(any()) } answers { + val src = firstArg() + partsBySource[src] ?: arrayListOf(src) + } + } + + // ---- tests ------------------------------------------------------------- + + @Test fun `no contacts enabled means no receiver registered and no sends`() { + seedContacts(contact("+15551111111", enabled = false)) + val recordingCtx = RecordingContext(appCtx) + val sms = mockSms(emptyMap()) + + AlertMessageSender(recordingCtx, sms).sendAlertMessage() + + assertEquals("no receiver should register when nothing will be sent", + 0, recordingCtx.registered.size) + verify(exactly = 0) { sms.sendTextMessage(any(), any(), any(), any(), any()) } + verify(exactly = 0) { sms.sendMultipartTextMessage(any(), any(), any(), any(), any()) } + } + + @Test fun `blank phone or blank message is skipped entirely`() { + seedContacts( + contact("", msg = "help"), // blank phone + contact("+15550000001", msg = ""), // blank message + contact("+15550000002", msg = "real") // OK + ) + val recordingCtx = RecordingContext(appCtx) + val sms = mockSms(mapOf("real" to arrayListOf("real"))) + + AlertMessageSender(recordingCtx, sms).sendAlertMessage() + + assertEquals(1, recordingCtx.registered.size) + verify(exactly = 1) { sms.sendTextMessage("+15550000002", null, "real", any(), null) } + } + + @Test fun `single contact with single-part message registers one receiver and sends once`() { + val recordingCtx = RecordingContext(appCtx) + val sms = mockSms(mapOf("help" to arrayListOf("help"))) + + AlertMessageSender(recordingCtx, sms).sendAlertMessage() + + assertEquals("exactly one SMSSentReceiver should be registered per batch", + 1, recordingCtx.registered.size) + verify(exactly = 1) { sms.sendTextMessage("+15551111111", null, "help", any(), null) } + verify(exactly = 0) { sms.sendMultipartTextMessage(any(), any(), any(), any(), any()) } + } + + @Test fun `single contact with multi-part message uses sendMultipart with one PI per part`() { + seedContacts(contact("+15551111111", msg = "long message")) + val recordingCtx = RecordingContext(appCtx) + val sms = mockSms(mapOf("long message" to arrayListOf("long ", "message"))) + + val partsSlot = slot>() + val pisSlot = slot>() + + AlertMessageSender(recordingCtx, sms).sendAlertMessage() + + verify(exactly = 1) { + sms.sendMultipartTextMessage( + eq("+15551111111"), + isNull(), + capture(partsSlot), + capture(pisSlot), + isNull() + ) + } + assertEquals(2, partsSlot.captured.size) + assertEquals("one PendingIntent per part, all the same", 2, pisSlot.captured.size) + assertSame(pisSlot.captured[0], pisSlot.captured[1]) + } + + @Test fun `multiple contacts register a single receiver, not one per contact`() { + // This is the fix: before, the receiver was registered inside the loop + // (one per contact). With 3 contacts we'd see 3 registrations. After + // the fix we should see exactly 1. + seedContacts( + contact("+15550000001"), + contact("+15550000002"), + contact("+15550000003") + ) + val recordingCtx = RecordingContext(appCtx) + val sms = mockSms(mapOf("help" to arrayListOf("help"))) + + AlertMessageSender(recordingCtx, sms).sendAlertMessage() + + assertEquals("exactly ONE receiver per batch — not one per contact", + 1, recordingCtx.registered.size) + verify(exactly = 3) { sms.sendTextMessage(any(), any(), any(), any(), any()) } + } + + @Test fun `null smsManager short-circuits with no sends and no receiver`() { + val recordingCtx = RecordingContext(appCtx) + + AlertMessageSender(recordingCtx, smsManager = null).sendAlertMessage() + + assertEquals(0, recordingCtx.registered.size) + } + + @Test fun `mix of single-part and multi-part contacts produces one receiver`() { + // expectedBroadcasts should be the sum across contacts (1 + 2 = 3). + // We verify the receiver registration count and the send call counts; + // the SMSSentReceiver counter itself is covered in SMSSentReceiverTest. + seedContacts( + contact("+15550000001", msg = "short"), + contact("+15550000002", msg = "long one") + ) + val recordingCtx = RecordingContext(appCtx) + val sms = mockSms(mapOf( + "short" to arrayListOf("short"), + "long one" to arrayListOf("long ", "one") + )) + + AlertMessageSender(recordingCtx, sms).sendAlertMessage() + + assertEquals(1, recordingCtx.registered.size) + assertNotNull(recordingCtx.registered[0]) + verify(exactly = 1) { sms.sendTextMessage("+15550000001", null, "short", any(), null) } + verify(exactly = 1) { sms.sendMultipartTextMessage("+15550000002", null, any(), any(), null) } + } + + @Test fun `test warning message is sent first and does not use sentIntent`() { + val recordingCtx = RecordingContext(appCtx) + val sms = mockSms(mapOf("help" to arrayListOf("help"))) + + AlertMessageSender(recordingCtx, sms).sendAlertMessage(testWarningMessage = "about to test") + + // Warning first, with null sentIntent; then the real alert. + verify(exactly = 1) { + sms.sendTextMessage("+15551111111", null, "about to test", isNull(), isNull()) + } + verify(exactly = 1) { + sms.sendTextMessage("+15551111111", null, "help", any(), isNull()) + } + } +} diff --git a/app/src/test/java/io/keepalive/android/DeviceActivityQueryTest.kt b/app/src/test/java/io/keepalive/android/DeviceActivityQueryTest.kt new file mode 100644 index 0000000..48c570a --- /dev/null +++ b/app/src/test/java/io/keepalive/android/DeviceActivityQueryTest.kt @@ -0,0 +1,96 @@ +package io.keepalive.android + +import android.app.usage.UsageEvents +import android.app.usage.UsageStatsManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import io.keepalive.android.testing.fakeUsageEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * Tests the UsageStatsManager query that drives activity detection. Uses + * Robolectric's shadow UsageStatsManager to seed events. + */ +@RunWith(RobolectricTestRunner::class) +class DeviceActivityQueryTest { + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + private val usm = appCtx.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager + + private fun addEvent(pkg: String, eventType: Int, timestamp: Long) { + // UsageEvents.Event has no public constructor, so we build it via the + // same reflection helper used by FakeAlertCheckDeps. + shadowOf(usm).addEvent( + fakeUsageEvent(packageName = pkg, timeStamp = timestamp, eventType = eventType) + ) + } + + @Test fun `returns null when no events are present`() { + val result = getLastDeviceActivity( + appCtx, + startTimestamp = 0L, + monitoredApps = null + ) + assertNull(result) + } + + @Test fun `returns most recent matching system keyguard event when no apps specified`() { + addEvent("android", UsageEvents.Event.KEYGUARD_HIDDEN, 1_000L) + addEvent("android", UsageEvents.Event.KEYGUARD_SHOWN, 5_000L) + addEvent("android", UsageEvents.Event.KEYGUARD_HIDDEN, 3_000L) + + val result = getLastDeviceActivity( + appCtx, + startTimestamp = 0L, + monitoredApps = null + ) + + assertEquals(5_000L, result?.timeStamp) + assertEquals(UsageEvents.Event.KEYGUARD_SHOWN, result?.eventType) + } + + @Test fun `ignores events for non-monitored packages`() { + addEvent("com.example.other", UsageEvents.Event.ACTIVITY_RESUMED, 5_000L) + addEvent("com.example.target", UsageEvents.Event.ACTIVITY_RESUMED, 2_000L) + + val result = getLastDeviceActivity( + appCtx, + startTimestamp = 0L, + monitoredApps = listOf("com.example.target") + ) + + assertEquals("should only see the target app's event", 2_000L, result?.timeStamp) + } + + @Test fun `ignores events outside the window`() { + addEvent("android", UsageEvents.Event.KEYGUARD_HIDDEN, 500L) + + // Query starts at 1000 — earlier event should be excluded. + val result = getLastDeviceActivity( + appCtx, + startTimestamp = 1_000L, + monitoredApps = null + ) + + assertNull(result) + } + + @Test fun `empty monitored apps list falls back to system package and finds keyguard events`() { + addEvent("android", UsageEvents.Event.KEYGUARD_HIDDEN, 2_000L) + + val result = getLastDeviceActivity( + appCtx, + startTimestamp = 0L, + monitoredApps = emptyList() + ) + + // API 28+: empty list triggers the fallback to system keyguard monitoring. + // Robolectric runs at SDK 35 via properties, so we exercise that path. + assertEquals(2_000L, result?.timeStamp) + } +} diff --git a/app/src/test/java/io/keepalive/android/DoAlertCheckTest.kt b/app/src/test/java/io/keepalive/android/DoAlertCheckTest.kt new file mode 100644 index 0000000..b84ad5b --- /dev/null +++ b/app/src/test/java/io/keepalive/android/DoAlertCheckTest.kt @@ -0,0 +1,286 @@ +package io.keepalive.android + +import io.keepalive.android.testing.FakeAlertCheckDeps +import io.keepalive.android.testing.fakeUsageEvent +import io.keepalive.android.testing.hours +import io.keepalive.android.testing.minutes +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests for the [doAlertCheck] state machine. All side effects are captured + * by [FakeAlertCheckDeps] — no AlarmManager / UsageStatsManager / Context are + * actually hit. Runs under Robolectric only because the engine routes some + * logging through `android.util.Log`, whose plain unit-test stub doesn't + * include the 3-arg overload. Robolectric provides the real Log shadow. + */ +@RunWith(RobolectricTestRunner::class) +class DoAlertCheckTest { + + private lateinit var deps: FakeAlertCheckDeps + + // Defaults: 12h check period, 60-min followup. Matches production defaults. + @Before fun setUp() { + deps = FakeAlertCheckDeps() + deps.credPrefs.edit() + .putString("time_period_hours", "12") + .putString("followup_time_period_minutes", "60") + .apply() + } + + // ===================================================================== + // Direct Boot branch (user locked) + // ===================================================================== + + @Test fun `direct boot with no saved alarm timestamp schedules periodic from now`() { + deps.userUnlockedValue = false + deps.nowValue = hours(5) + // devPrefs has no NextAlarmTimestamp + + doAlertCheck(deps, "periodic") + + assertEquals(1, deps.scheduledAlarms.size) + val scheduled = deps.scheduledAlarms[0] + assertEquals(hours(5), scheduled.baseTimestamp) + assertEquals(12 * 60, scheduled.periodMinutes) + assertEquals("periodic", scheduled.stage) + // No user-facing side effects + assertEquals(0, deps.notificationShowCount) + assertEquals(0, deps.finalAlertCalls.size) + } + + @Test fun `direct boot periodic alarm not yet due reschedules`() { + deps.userUnlockedValue = false + deps.nowValue = hours(10) + deps.devPrefs.edit().putLong("NextAlarmTimestamp", hours(12)).apply() + + doAlertCheck(deps, "periodic") + + assertEquals(1, deps.scheduledAlarms.size) + val scheduled = deps.scheduledAlarms[0] + // Reconstructed base time = savedAlarm - periodMinutes*60*1000 + assertEquals(hours(12) - 12 * 60 * 60 * 1000L, scheduled.baseTimestamp) + assertEquals("periodic", scheduled.stage) + // restPeriods passed as null to avoid re-adjusting + assertNull(scheduled.restPeriods) + } + + @Test fun `direct boot final alarm not yet due reschedules with followup period`() { + deps.userUnlockedValue = false + deps.nowValue = hours(12) + minutes(30) + deps.devPrefs.edit().putLong("NextAlarmTimestamp", hours(13)).apply() + + doAlertCheck(deps, "final") + + assertEquals(1, deps.scheduledAlarms.size) + val scheduled = deps.scheduledAlarms[0] + assertEquals("final", scheduled.stage) + assertEquals(60, scheduled.periodMinutes) + // No alert yet — just a reschedule + assertEquals(0, deps.finalAlertCalls.size) + } + + @Test fun `direct boot final alarm due with no activity dispatches final alert`() { + deps.userUnlockedValue = false + deps.nowValue = hours(13) + deps.devPrefs.edit() + .putLong("NextAlarmTimestamp", hours(13)) + // last_activity_timestamp: older than 'are you there' posted-at (hours(12)) + .putLong("last_activity_timestamp", hours(5)) + .putBoolean("direct_boot_notification_pending", true) + .apply() + + doAlertCheck(deps, "final") + + assertEquals(1, deps.finalAlertCalls.size) + // The flag should be cleared so a subsequent reboot doesn't re-alert + assertFalse(deps.devPrefs.getBoolean("direct_boot_notification_pending", true)) + } + + @Test fun `direct boot final alarm skipped when activity recorded after prompt`() { + // This is the race case #4 we fixed: user unlocked (or tapped) at hours(12)+30min, + // then final alarm fires a moment later. last_activity_timestamp > are-you-there + // posted-at means we should acknowledge, not alert. + deps.userUnlockedValue = false + deps.nowValue = hours(13) + deps.devPrefs.edit() + .putLong("NextAlarmTimestamp", hours(13)) + .putLong("last_activity_timestamp", hours(12) + minutes(30)) + .putBoolean("direct_boot_notification_pending", true) + .apply() + + doAlertCheck(deps, "final") + + assertEquals("should not dispatch the final alert", 0, deps.finalAlertCalls.size) + assertEquals("should acknowledge instead", 1, deps.acknowledgeCount) + } + + @Test fun `direct boot final alarm with activity exactly at prompt is treated as acknowledged`() { + // Boundary: equals is considered "acknowledged" (>=). + deps.userUnlockedValue = false + deps.nowValue = hours(13) + deps.devPrefs.edit() + .putLong("NextAlarmTimestamp", hours(13)) + .putLong("last_activity_timestamp", hours(12)) // exactly at prompt time + .apply() + + doAlertCheck(deps, "final") + + assertEquals(0, deps.finalAlertCalls.size) + assertEquals(1, deps.acknowledgeCount) + } + + @Test fun `direct boot periodic due shows notification and schedules final alarm`() { + deps.userUnlockedValue = false + deps.nowValue = hours(12) + deps.devPrefs.edit().putLong("NextAlarmTimestamp", hours(12)).apply() + + doAlertCheck(deps, "periodic") + + assertEquals(1, deps.notificationShowCount) + assertEquals(60, deps.notificationLastFollowupMinutes) + // Final alarm scheduled + assertEquals(1, deps.scheduledAlarms.size) + assertEquals("final", deps.scheduledAlarms[0].stage) + // Direct-boot pending flag set so BOOT_COMPLETED knows to ack on unlock + assertTrue(deps.devPrefs.getBoolean("direct_boot_notification_pending", false)) + // No overlay during Direct Boot (service not directBootAware, screen not visible) + assertEquals(0, deps.overlayShowCount) + } + + // ===================================================================== + // Unlocked branch + // ===================================================================== + + @Test fun `unlocked with no recent activity sends prompt and schedules final`() { + deps.userUnlockedValue = true + deps.nowValue = hours(24) // well past a check period + deps.lastActivity = null + + doAlertCheck(deps, "periodic") + + assertEquals(1, deps.notificationShowCount) + assertEquals(1, deps.overlayShowCount) + assertEquals(60, deps.overlayLastFollowupMinutes) + assertEquals(1, deps.scheduledAlarms.size) + assertEquals("final", deps.scheduledAlarms[0].stage) + assertEquals(60, deps.scheduledAlarms[0].periodMinutes) + // No final alert yet + assertEquals(0, deps.finalAlertCalls.size) + } + + @Test fun `unlocked with recent activity reschedules periodic`() { + deps.userUnlockedValue = true + deps.nowValue = hours(24) + deps.lastActivity = fakeUsageEvent(timeStamp = hours(23)) + + doAlertCheck(deps, "periodic") + + assertEquals(0, deps.notificationShowCount) + assertEquals(0, deps.overlayShowCount) + assertEquals(1, deps.scheduledAlarms.size) + val scheduled = deps.scheduledAlarms[0] + assertEquals("periodic", scheduled.stage) + // Base is the last-activity timestamp so the next alarm fires checkPeriod later + assertEquals(hours(23), scheduled.baseTimestamp) + // last_activity_timestamp written to device-protected storage for Direct Boot recovery + assertEquals(hours(23), deps.devPrefs.getLong("last_activity_timestamp", -1L)) + } + + @Test fun `unlocked final alarm with no activity dispatches final alert`() { + deps.userUnlockedValue = true + deps.nowValue = hours(13) + deps.lastActivity = null + + doAlertCheck(deps, "final") + + assertEquals(1, deps.finalAlertCalls.size) + // Should NOT also schedule a prompt notification + assertEquals(0, deps.notificationShowCount) + } + + @Test fun `unlocked final alarm with recent activity reschedules periodic and skips alert`() { + deps.userUnlockedValue = true + deps.nowValue = hours(13) + deps.lastActivity = fakeUsageEvent(timeStamp = hours(12) + minutes(30)) + + doAlertCheck(deps, "final") + + assertEquals("should not dispatch alert when user returned", 0, deps.finalAlertCalls.size) + assertEquals(1, deps.scheduledAlarms.size) + assertEquals("periodic", deps.scheduledAlarms[0].stage) + } + + @Test fun `unlocked every check writes last_check_timestamp to device prefs`() { + deps.userUnlockedValue = true + deps.nowValue = hours(24) + + doAlertCheck(deps, "periodic") + + assertEquals(hours(24), deps.devPrefs.getLong("last_check_timestamp", -1L)) + } + + @Test fun `unlocked without activity does not write last_activity_timestamp`() { + // last_activity_timestamp is only written when lastInteractiveEvent != null. + // An old value from a prior cycle should persist untouched. + deps.userUnlockedValue = true + deps.nowValue = hours(24) + deps.devPrefs.edit().putLong("last_activity_timestamp", hours(10)).apply() + deps.lastActivity = null + + doAlertCheck(deps, "periodic") + + assertEquals("must not overwrite stale activity", hours(10), + deps.devPrefs.getLong("last_activity_timestamp", -1L)) + } + + @Test fun `unlocked query uses the monitored app packages from prefs`() { + deps.userUnlockedValue = true + deps.nowValue = hours(24) + // APPS_TO_MONITOR is a JSON list + val appsJson = """[ + {"packageName":"com.whatsapp","appName":"WhatsApp","lastUsed":0,"className":""}, + {"packageName":"com.google.android.gm","appName":"Gmail","lastUsed":0,"className":""} + ]""".trimIndent() + deps.credPrefs.edit().putString("APPS_TO_MONITOR", appsJson).apply() + + doAlertCheck(deps, "periodic") + + assertEquals(1, deps.activityQueries.size) + val (_, apps) = deps.activityQueries[0] + assertEquals(listOf("com.whatsapp", "com.google.android.gm"), apps) + } + + @Test fun `unlocked with empty apps-to-monitor falls back to system events`() { + deps.userUnlockedValue = true + deps.nowValue = hours(24) + // No APPS_TO_MONITOR set — engine passes an empty list and DeviceActivityQuery + // then falls back to "android" keyguard events. The engine itself just passes + // whatever apps it loaded. + doAlertCheck(deps, "periodic") + + assertEquals(1, deps.activityQueries.size) + val (_, apps) = deps.activityQueries[0] + assertTrue("empty when no monitored apps configured", apps.isEmpty()) + } + + @Test fun `checkPeriodHours below 1 is honored`() { + // e.g. 0.5 hours (30 minutes). Division happens in floats. + deps.credPrefs.edit().putString("time_period_hours", "0.5").apply() + deps.userUnlockedValue = true + deps.nowValue = hours(2) + deps.lastActivity = fakeUsageEvent(timeStamp = hours(2) - minutes(10)) + + doAlertCheck(deps, "periodic") + + assertEquals(1, deps.scheduledAlarms.size) + // period = 0.5h * 60 = 30 minutes + assertEquals(30, deps.scheduledAlarms[0].periodMinutes) + } +} diff --git a/app/src/test/java/io/keepalive/android/GetSavedReferenceTimestampTest.kt b/app/src/test/java/io/keepalive/android/GetSavedReferenceTimestampTest.kt new file mode 100644 index 0000000..345b058 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/GetSavedReferenceTimestampTest.kt @@ -0,0 +1,45 @@ +package io.keepalive.android + +import io.keepalive.android.testing.FakeSharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Pure tests for the tiny preference-lookup helper. */ +class GetSavedReferenceTimestampTest { + + @Test fun `prefers last_activity_timestamp when set`() { + val prefs = FakeSharedPreferences().apply { + seed("last_activity_timestamp", 1_000L) + seed("last_check_timestamp", 2_000L) + } + assertEquals(1_000L, getSavedReferenceTimestamp(prefs, fallback = 9_000L)) + } + + @Test fun `falls back to last_check_timestamp when no activity timestamp`() { + val prefs = FakeSharedPreferences().apply { + seed("last_check_timestamp", 2_000L) + } + assertEquals(2_000L, getSavedReferenceTimestamp(prefs, fallback = 9_000L)) + } + + @Test fun `returns fallback when neither is set`() { + val prefs = FakeSharedPreferences() + assertEquals(9_000L, getSavedReferenceTimestamp(prefs, fallback = 9_000L)) + } + + @Test fun `negative or zero activity timestamp falls through to check timestamp`() { + val prefs = FakeSharedPreferences().apply { + seed("last_activity_timestamp", 0L) + seed("last_check_timestamp", 2_000L) + } + assertEquals(2_000L, getSavedReferenceTimestamp(prefs, fallback = 9_000L)) + } + + @Test fun `negative check timestamp falls through to fallback`() { + val prefs = FakeSharedPreferences().apply { + seed("last_activity_timestamp", -1L) + seed("last_check_timestamp", -1L) + } + assertEquals(9_000L, getSavedReferenceTimestamp(prefs, fallback = 9_000L)) + } +} diff --git a/app/src/test/java/io/keepalive/android/ToolchainSmokeTest.kt b/app/src/test/java/io/keepalive/android/ToolchainSmokeTest.kt new file mode 100644 index 0000000..29dae51 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/ToolchainSmokeTest.kt @@ -0,0 +1,32 @@ +package io.keepalive.android + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Smoke test: proves Robolectric 4.16 boots against compileSdk 36 / AGP 9 / + * Kotlin 2.3. If this file stops compiling or this test stops passing, do + * not bother writing any of the other test files until it's fixed. + */ +@RunWith(RobolectricTestRunner::class) +class ToolchainSmokeTest { + + @Test fun `robolectric application context is available`() { + val ctx = ApplicationProvider.getApplicationContext() + assertNotNull(ctx) + // debug builds append ".debug" to the applicationId; accept either. + val pkg = ctx.packageName + assertEquals(true, pkg == "io.keepalive.android" || pkg == "io.keepalive.android.debug") + } + + @Test fun `mockk is on the classpath`() { + val m = io.mockk.mockk(relaxed = true) + m.run() + io.mockk.verify { m.run() } + } +} diff --git a/app/src/test/java/io/keepalive/android/receivers/AlarmReceiverTest.kt b/app/src/test/java/io/keepalive/android/receivers/AlarmReceiverTest.kt new file mode 100644 index 0000000..c03dec7 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/receivers/AlarmReceiverTest.kt @@ -0,0 +1,109 @@ +package io.keepalive.android.receivers + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import io.keepalive.android.doAlertCheck +import io.keepalive.android.getEncryptedSharedPreferences +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +private const val ALERT_FUNCTIONS_KT = "io.keepalive.android.AlertFunctionsKt" + +/** + * Tests the `enabled`-preference gate in [AlarmReceiver.onReceive] and the + * stage wiring. The deep logic of [doAlertCheck] is covered elsewhere — + * here we mock it to confirm *whether* and *with what stage* it's called. + */ +@RunWith(RobolectricTestRunner::class) +class AlarmReceiverTest { + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + + @Before fun setUp() { + mockkStatic(ALERT_FUNCTIONS_KT) + every { doAlertCheck(any(), any()) } returns Unit + // Default: app enabled + getEncryptedSharedPreferences(appCtx).edit() + .putBoolean("enabled", true) + .commit() + } + + @After fun tearDown() { + unmockkStatic(ALERT_FUNCTIONS_KT) + } + + @Test fun `onReceive short-circuits when app is disabled`() { + getEncryptedSharedPreferences(appCtx).edit().putBoolean("enabled", false).commit() + val intent = Intent().putExtra("AlarmStage", "periodic") + + AlarmReceiver().onReceive(appCtx, intent) + + verify(exactly = 0) { doAlertCheck(any(), any()) } + } + + @Test fun `onReceive calls doAlertCheck with periodic stage from intent`() { + val intent = Intent().putExtra("AlarmStage", "periodic") + + AlarmReceiver().onReceive(appCtx, intent) + + verify(exactly = 1) { doAlertCheck(any(), "periodic") } + } + + @Test fun `onReceive calls doAlertCheck with final stage from intent`() { + val intent = Intent().putExtra("AlarmStage", "final") + + AlarmReceiver().onReceive(appCtx, intent) + + verify(exactly = 1) { doAlertCheck(any(), "final") } + } + + @Test fun `onReceive with no AlarmStage extra defaults to periodic`() { + AlarmReceiver().onReceive(appCtx, Intent()) + + verify(exactly = 1) { doAlertCheck(any(), "periodic") } + } + + @Test fun `onReceive swallows exceptions from doAlertCheck`() { + // BroadcastReceiver.onReceive must not throw — we log and move on. + // Verified by asserting onReceive returns normally. + every { doAlertCheck(any(), any()) } throws RuntimeException("boom") + val intent = Intent().putExtra("AlarmStage", "periodic") + + AlarmReceiver().onReceive(appCtx, intent) // must not throw + } + + @Test fun `final alarm that fired way after schedule downgrades to periodic`() { + // The stale-final heuristic: if delaySeconds > followupMinutes*60, stage + // is downgraded. Followup default is 60 min = 3600s. Simulate a delay + // of 2 hours. + val now = System.currentTimeMillis() + val twoHoursAgo = now - 2 * 60 * 60 * 1000L + val intent = Intent() + .putExtra("AlarmStage", "final") + .putExtra("AlarmTimestamp", twoHoursAgo) + + AlarmReceiver().onReceive(appCtx, intent) + + verify(exactly = 1) { doAlertCheck(any(), "periodic") } + verify(exactly = 0) { doAlertCheck(any(), "final") } + } + + @Test fun `fresh final alarm stays final`() { + // Fired on time (delaySeconds near 0). The rule keeps it as final. + val intent = Intent() + .putExtra("AlarmStage", "final") + .putExtra("AlarmTimestamp", System.currentTimeMillis()) + + AlarmReceiver().onReceive(appCtx, intent) + + verify(exactly = 1) { doAlertCheck(any(), "final") } + } +} diff --git a/app/src/test/java/io/keepalive/android/receivers/AlarmStageRuleTest.kt b/app/src/test/java/io/keepalive/android/receivers/AlarmStageRuleTest.kt new file mode 100644 index 0000000..ea8f9ca --- /dev/null +++ b/app/src/test/java/io/keepalive/android/receivers/AlarmStageRuleTest.kt @@ -0,0 +1,57 @@ +package io.keepalive.android.receivers + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Pure unit tests for the stale-final downgrade rule. */ +class AlarmStageRuleTest { + + @Test fun `periodic stays periodic regardless of delay`() { + assertEquals("periodic", + computeEffectiveAlarmStage("periodic", delaySeconds = 10_000_000L, followupMinutes = 60)) + } + + @Test fun `final with no delay stays final`() { + assertEquals("final", + computeEffectiveAlarmStage("final", delaySeconds = 0L, followupMinutes = 60)) + } + + @Test fun `final with negative delay stays final`() { + // delaySeconds <= 0 means the alarm fired early or at time — shouldn't happen + // on real AlarmManager but defensively keep the stage. + assertEquals("final", + computeEffectiveAlarmStage("final", delaySeconds = -5L, followupMinutes = 60)) + } + + @Test fun `final with delay less than followup stays final`() { + // 30 min delay, 60 min followup → still final + assertEquals("final", + computeEffectiveAlarmStage("final", delaySeconds = 30 * 60L, followupMinutes = 60)) + } + + @Test fun `final with delay exactly equal to followup stays final`() { + // Boundary: equals is NOT greater, so still final. + assertEquals("final", + computeEffectiveAlarmStage("final", delaySeconds = 60 * 60L, followupMinutes = 60)) + } + + @Test fun `final with delay one second past followup downgrades to periodic`() { + assertEquals("periodic", + computeEffectiveAlarmStage("final", delaySeconds = 60 * 60L + 1, followupMinutes = 60)) + } + + @Test fun `final with very long delay downgrades to periodic`() { + // e.g. device-off for a day, app update, etc. + assertEquals("periodic", + computeEffectiveAlarmStage("final", delaySeconds = 24 * 60 * 60L, followupMinutes = 60)) + } + + @Test fun `rule respects configured followup period`() { + // With a 15-minute followup window, 20-minute delay is stale. + assertEquals("periodic", + computeEffectiveAlarmStage("final", delaySeconds = 20 * 60L, followupMinutes = 15)) + // With a 120-minute followup window, 20-minute delay is fine. + assertEquals("final", + computeEffectiveAlarmStage("final", delaySeconds = 20 * 60L, followupMinutes = 120)) + } +} diff --git a/app/src/test/java/io/keepalive/android/receivers/BootBroadcastReceiverTest.kt b/app/src/test/java/io/keepalive/android/receivers/BootBroadcastReceiverTest.kt new file mode 100644 index 0000000..402a742 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/receivers/BootBroadcastReceiverTest.kt @@ -0,0 +1,138 @@ +package io.keepalive.android.receivers + +import android.content.Context +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import io.keepalive.android.AcknowledgeAreYouThere +import io.keepalive.android.doAlertCheck +import io.keepalive.android.getDeviceProtectedPreferences +import io.keepalive.android.getEncryptedSharedPreferences +import io.keepalive.android.isUserUnlocked +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Tests the boot-intent branching that decides whether to acknowledge a + * pending "Are you there?" prompt or run a fresh alert check after reboot. + * + * Mocks out [doAlertCheck] (covered elsewhere), [AcknowledgeAreYouThere] (same), + * and [isUserUnlocked] (stateful in Robolectric but brittle to toggle). We + * only assert on *which* of these gets called per boot scenario. + */ +@RunWith(RobolectricTestRunner::class) +class BootBroadcastReceiverTest { + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + private val ALERT_FUNCTIONS_KT = "io.keepalive.android.AlertFunctionsKt" + private val UTILITY_FUNCTIONS_KT = "io.keepalive.android.UtilityFunctionsKt" + + @Before fun setUp() { + mockkStatic(ALERT_FUNCTIONS_KT) + mockkStatic(UTILITY_FUNCTIONS_KT) + mockkObject(AcknowledgeAreYouThere) + every { doAlertCheck(any(), any()) } returns Unit + every { AcknowledgeAreYouThere.acknowledge(any()) } returns Unit + // Default: app enabled, user unlocked + every { isUserUnlocked(any()) } returns true + getEncryptedSharedPreferences(appCtx).edit() + .putBoolean("enabled", true) + .commit() + getDeviceProtectedPreferences(appCtx).edit() + .putString("last_alarm_stage", "periodic") + .putBoolean("direct_boot_notification_pending", false) + .commit() + } + + @After fun tearDown() { + unmockkStatic(ALERT_FUNCTIONS_KT) + unmockkStatic(UTILITY_FUNCTIONS_KT) + unmockkObject(AcknowledgeAreYouThere) + } + + @Test fun `receiver does nothing when app is disabled`() { + getEncryptedSharedPreferences(appCtx).edit().putBoolean("enabled", false).commit() + val intent = Intent(Intent.ACTION_BOOT_COMPLETED) + + BootBroadcastReceiver().onReceive(appCtx, intent) + + verify(exactly = 0) { doAlertCheck(any(), any()) } + verify(exactly = 0) { AcknowledgeAreYouThere.acknowledge(any()) } + } + + @Test fun `receiver ignores unrelated intents`() { + BootBroadcastReceiver().onReceive(appCtx, Intent("something.else")) + + verify(exactly = 0) { doAlertCheck(any(), any()) } + verify(exactly = 0) { AcknowledgeAreYouThere.acknowledge(any()) } + } + + @Test fun `BOOT_COMPLETED with pending flag triggers acknowledge`() { + // User just unlocked — the unlock IS the acknowledgement. We must NOT + // re-run doAlertCheck with stage=final (which would otherwise alert). + getDeviceProtectedPreferences(appCtx).edit() + .putBoolean("direct_boot_notification_pending", true) + .putString("last_alarm_stage", "final") + .commit() + + BootBroadcastReceiver().onReceive(appCtx, Intent(Intent.ACTION_BOOT_COMPLETED)) + + verify(exactly = 1) { AcknowledgeAreYouThere.acknowledge(any()) } + verify(exactly = 0) { doAlertCheck(any(), any()) } + } + + @Test fun `BOOT_COMPLETED without pending flag runs doAlertCheck with saved stage`() { + getDeviceProtectedPreferences(appCtx).edit() + .putBoolean("direct_boot_notification_pending", false) + .putString("last_alarm_stage", "periodic") + .commit() + + BootBroadcastReceiver().onReceive(appCtx, Intent(Intent.ACTION_BOOT_COMPLETED)) + + verify(exactly = 1) { doAlertCheck(any(), "periodic") } + verify(exactly = 0) { AcknowledgeAreYouThere.acknowledge(any()) } + } + + @Test fun `BOOT_COMPLETED without pending flag honors a final saved stage`() { + getDeviceProtectedPreferences(appCtx).edit() + .putBoolean("direct_boot_notification_pending", false) + .putString("last_alarm_stage", "final") + .commit() + + BootBroadcastReceiver().onReceive(appCtx, Intent(Intent.ACTION_BOOT_COMPLETED)) + + verify(exactly = 1) { doAlertCheck(any(), "final") } + } + + @Test fun `LOCKED_BOOT_COMPLETED when user is unlocked is skipped`() { + // During an app redeploy/update, both LOCKED_BOOT_COMPLETED and + // BOOT_COMPLETED fire while the device is already unlocked. The + // BOOT_COMPLETED handler will take care of things. + every { isUserUnlocked(any()) } returns true + + BootBroadcastReceiver().onReceive( + appCtx, Intent("android.intent.action.LOCKED_BOOT_COMPLETED")) + + verify(exactly = 0) { doAlertCheck(any(), any()) } + } + + @Test fun `LOCKED_BOOT_COMPLETED while still locked runs doAlertCheck with saved stage`() { + every { isUserUnlocked(any()) } returns false + getDeviceProtectedPreferences(appCtx).edit() + .putString("last_alarm_stage", "periodic") + .commit() + + BootBroadcastReceiver().onReceive( + appCtx, Intent("android.intent.action.LOCKED_BOOT_COMPLETED")) + + verify(exactly = 1) { doAlertCheck(any(), "periodic") } + } +} diff --git a/app/src/test/java/io/keepalive/android/receivers/SMSSentReceiverTest.kt b/app/src/test/java/io/keepalive/android/receivers/SMSSentReceiverTest.kt new file mode 100644 index 0000000..206b10a --- /dev/null +++ b/app/src/test/java/io/keepalive/android/receivers/SMSSentReceiverTest.kt @@ -0,0 +1,88 @@ +package io.keepalive.android.receivers + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Verifies [SMSSentReceiver] only unregisters itself after receiving the + * number of broadcasts it was constructed for. Guards against the pre-fix + * behavior where the receiver unregistered on the first broadcast and + * subsequent SMS results went unreported. + */ +@RunWith(RobolectricTestRunner::class) +class SMSSentReceiverTest { + + /** + * Real Robolectric context but counts `unregisterReceiver` calls and can + * be configured to throw IAE (the pre-unregistered case). Using a real + * Context means AlertNotificationHelper construction works. + */ + private class CountingContext( + base: Context, + private val throwOnUnregister: Boolean = false + ) : ContextWrapper(base) { + var unregisterCount = 0 + private set + + override fun unregisterReceiver(receiver: BroadcastReceiver?) { + unregisterCount++ + if (throwOnUnregister) throw IllegalArgumentException("already unregistered") + } + } + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + + @Test fun `does not unregister until expected broadcasts arrive`() { + val ctx = CountingContext(appCtx) + val receiver = SMSSentReceiver(expectedBroadcasts = 3) + val intent = Intent("SMS_SENT") + + receiver.onReceive(ctx, intent) + receiver.onReceive(ctx, intent) + assertEquals("should not unregister before all broadcasts arrived", + 0, ctx.unregisterCount) + + receiver.onReceive(ctx, intent) + assertEquals("should unregister on final expected broadcast", + 1, ctx.unregisterCount) + } + + @Test fun `single-broadcast receiver unregisters on first onReceive`() { + val ctx = CountingContext(appCtx) + val receiver = SMSSentReceiver(expectedBroadcasts = 1) + + receiver.onReceive(ctx, Intent("SMS_SENT")) + + assertEquals(1, ctx.unregisterCount) + } + + @Test fun `default constructor acts as single-broadcast`() { + val ctx = CountingContext(appCtx) + val receiver = SMSSentReceiver() + + receiver.onReceive(ctx, Intent("SMS_SENT")) + + assertEquals(1, ctx.unregisterCount) + } + + @Test fun `stray extra broadcast past zero still attempts unregister and catches IAE`() { + // If the safety-net unregister already ran and the receiver still + // gets an intent, onReceive should not crash. + val ctx = CountingContext(appCtx, throwOnUnregister = true) + val receiver = SMSSentReceiver(expectedBroadcasts = 1) + + receiver.onReceive(ctx, Intent("SMS_SENT")) + receiver.onReceive(ctx, Intent("SMS_SENT")) // counter is now negative + + // Both calls attempted unregister; the IAE was swallowed both times. + assertEquals(2, ctx.unregisterCount) + } + +} diff --git a/app/src/test/java/io/keepalive/android/testing/FakeAlertCheckDeps.kt b/app/src/test/java/io/keepalive/android/testing/FakeAlertCheckDeps.kt new file mode 100644 index 0000000..b78ed13 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/testing/FakeAlertCheckDeps.kt @@ -0,0 +1,127 @@ +package io.keepalive.android.testing + +import android.app.usage.UsageEvents +import android.content.SharedPreferences +import io.keepalive.android.AlertCheckDeps +import io.keepalive.android.RestPeriod +import java.lang.reflect.Constructor + +/** + * Recording fake of [AlertCheckDeps] for pure-JVM tests of the alert decision engine. + * + * Default state: user unlocked, clock at T=0, empty prefs, no recent activity. + * Individual tests override via the public mutable fields before calling + * `doAlertCheck(deps, ...)`. + */ +class FakeAlertCheckDeps : AlertCheckDeps { + + // --- Controllable state --- + var nowValue: Long = 0L + var userUnlockedValue: Boolean = true + val credPrefs: FakeSharedPreferences = FakeSharedPreferences() + val devPrefs: FakeSharedPreferences = FakeSharedPreferences() + var lastActivity: UsageEvents.Event? = null + + /** Canned resource strings. Tests rarely need to customize this. */ + var getStringImpl: (Int, Array) -> String = { resId, args -> + if (args.isEmpty()) "res$resId" else "res$resId(${args.joinToString()})" + } + + // --- Recorded calls --- + data class ScheduledAlarm( + val baseTimestamp: Long, + val periodMinutes: Int, + val stage: String, + val restPeriods: MutableList? + ) + + data class FinalAlertCall( + val nowTimestamp: Long, + val checkPeriodHours: Float, + val restPeriods: MutableList + ) + + val scheduledAlarms = mutableListOf() + val activityQueries = mutableListOf>>() + var notificationShowCount = 0 + var notificationLastFollowupMinutes: Int? = null + var overlayShowCount = 0 + var overlayLastFollowupMinutes: Int? = null + var acknowledgeCount = 0 + val finalAlertCalls = mutableListOf() + + // --- AlertCheckDeps impl --- + + override fun now(): Long = nowValue + override fun isUserUnlocked(): Boolean = userUnlockedValue + override fun getString(resId: Int, vararg args: Any): String = getStringImpl(resId, args) + override fun credentialPrefs(): SharedPreferences = credPrefs + override fun devicePrefs(): SharedPreferences = devPrefs + + override fun getLastDeviceActivity(startTimestamp: Long, monitoredApps: List): UsageEvents.Event? { + activityQueries.add(startTimestamp to monitoredApps) + return lastActivity + } + + override fun scheduleAlarm( + baseTimestamp: Long, + periodMinutes: Int, + stage: String, + restPeriods: MutableList? + ) { + scheduledAlarms.add(ScheduledAlarm(baseTimestamp, periodMinutes, stage, restPeriods)) + } + + override fun showAreYouThereNotification(followupPeriodMinutes: Int) { + notificationShowCount++ + notificationLastFollowupMinutes = followupPeriodMinutes + } + + override fun showAreYouThereOverlay(followupPeriodMinutes: Int) { + overlayShowCount++ + overlayLastFollowupMinutes = followupPeriodMinutes + } + + override fun acknowledgeAreYouThere() { + acknowledgeCount++ + } + + override fun dispatchFinalAlert( + prefs: SharedPreferences, + nowTimestamp: Long, + checkPeriodHours: Float, + restPeriods: MutableList + ) { + finalAlertCalls.add(FinalAlertCall(nowTimestamp, checkPeriodHours, restPeriods)) + } +} + +/** + * Build a [UsageEvents.Event] for tests. The class has no public constructor so + * we reach in via reflection (stable across API 22 → 36). + */ +fun fakeUsageEvent( + packageName: String = "android", + timeStamp: Long = 0L, + eventType: Int = UsageEvents.Event.KEYGUARD_HIDDEN +): UsageEvents.Event { + val ctor: Constructor = + UsageEvents.Event::class.java.getDeclaredConstructor() + ctor.isAccessible = true + val event = ctor.newInstance() + setField(event, "mPackage", packageName) + setField(event, "mTimeStamp", timeStamp) + setField(event, "mEventType", eventType) + return event +} + +private fun setField(target: Any, name: String, value: Any?) { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + field.set(target, value) +} + +// --- small time helpers so tests read naturally --- + +fun hours(n: Int): Long = n * 60L * 60L * 1000L +fun minutes(n: Int): Long = n * 60L * 1000L diff --git a/app/src/test/java/io/keepalive/android/testing/FakeSharedPreferences.kt b/app/src/test/java/io/keepalive/android/testing/FakeSharedPreferences.kt new file mode 100644 index 0000000..5644c5e --- /dev/null +++ b/app/src/test/java/io/keepalive/android/testing/FakeSharedPreferences.kt @@ -0,0 +1,73 @@ +package io.keepalive.android.testing + +import android.content.SharedPreferences + +/** + * In-memory SharedPreferences for pure-JVM tests. + * + * Doesn't support listeners (KeepAlive's code never registers any). commit() + * and apply() both write synchronously to the backing map. + */ +class FakeSharedPreferences : SharedPreferences { + + private val data = mutableMapOf() + + @Suppress("UNCHECKED_CAST") + override fun getAll(): MutableMap = HashMap(data) as MutableMap + + override fun getString(key: String?, defValue: String?): String? { + val v = data[key] ?: return defValue + return v as? String ?: defValue + } + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? { + val v = data[key] ?: return defValues + return (v as? MutableSet) ?: defValues + } + + override fun getInt(key: String?, defValue: Int): Int = (data[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (data[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (data[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = (data[key] as? Boolean) ?: defValue + override fun contains(key: String?): Boolean = data.containsKey(key) + + override fun edit(): SharedPreferences.Editor = Editor() + + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + /* no-op */ + } + + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) { + /* no-op */ + } + + // Test-only helper: seed a value without going through Editor. + fun seed(key: String, value: Any?) { + if (value == null) data.remove(key) else data[key] = value + } + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private val removed = mutableSetOf() + private var clearAll = false + + override fun putString(key: String?, value: String?): SharedPreferences.Editor = also { pending[key!!] = value } + override fun putStringSet(key: String?, values: MutableSet?): SharedPreferences.Editor = also { pending[key!!] = values } + override fun putInt(key: String?, value: Int): SharedPreferences.Editor = also { pending[key!!] = value } + override fun putLong(key: String?, value: Long): SharedPreferences.Editor = also { pending[key!!] = value } + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = also { pending[key!!] = value } + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = also { pending[key!!] = value } + override fun remove(key: String?): SharedPreferences.Editor = also { removed.add(key!!) } + override fun clear(): SharedPreferences.Editor = also { clearAll = true } + + override fun commit(): Boolean { + if (clearAll) data.clear() + removed.forEach { data.remove(it) } + pending.forEach { (k, v) -> if (v == null) data.remove(k) else data[k] = v } + return true + } + + override fun apply() { commit() } + } +} From 0df78b3744bcae15888bd2af599f51d9d27c5a2b Mon Sep 17 00:00:00 2001 From: keepalivedev Date: Sun, 19 Apr 2026 13:07:19 -0500 Subject: [PATCH 04/26] refactor sendAlert to make it easier to test --- .../java/io/keepalive/android/AlertService.kt | 156 ++++------------- .../io/keepalive/android/AlertStepRunner.kt | 162 ++++++++++++++++++ 2 files changed, 195 insertions(+), 123 deletions(-) create mode 100644 app/src/main/java/io/keepalive/android/AlertStepRunner.kt diff --git a/app/src/main/java/io/keepalive/android/AlertService.kt b/app/src/main/java/io/keepalive/android/AlertService.kt index 1a98ce1..35612f1 100644 --- a/app/src/main/java/io/keepalive/android/AlertService.kt +++ b/app/src/main/java/io/keepalive/android/AlertService.kt @@ -21,14 +21,8 @@ class AlertService : Service() { /** Intent extra key used to stamp each alert with a unique trigger time. */ const val EXTRA_ALERT_TRIGGER_TIMESTAMP = "alert_trigger_timestamp" - // Bitmask constants for idempotent step tracking. - // Each bit represents one discrete step in the alert flow. - private const val STEP_SMS_SENT = 1 // SMS messages dispatched to all contacts - private const val STEP_CALL_MADE = 2 // Phone call placed - private const val STEP_LOCATION_DONE = 4 // Location SMS sent (or not needed / failed) - private const val STEP_WEBHOOK_DONE = 8 // Webhook request sent (or not needed / failed) - private const val ALL_STEPS_COMPLETE = - STEP_SMS_SENT or STEP_CALL_MADE or STEP_LOCATION_DONE or STEP_WEBHOOK_DONE + // Step-tracker bitmask constants live in AlertStepRunner.kt so the + // pure sequencing logic can share them with this service. // SharedPreferences keys for the step tracker. // The trigger timestamp identifies *which* alert cycle the steps belong to. @@ -305,128 +299,44 @@ class AlertService : Service() { private fun sendAlert(context: Context, prefs: SharedPreferences) { DebugLogger.d("sendAlert", context.getString(R.string.debug_log_sending_alert)) - // cancel the 'Are you there?' notification (idempotent — safe to repeat) - AlertNotificationHelper(context).cancelNotification( - AppController.ARE_YOU_THERE_NOTIFICATION_ID - ) - - // dismiss any 'Are you there?' overlay if it's showing - // skip during Direct Boot since AreYouThereOverlayService is not directBootAware - if (isUserUnlocked(context)) { - AreYouThereOverlay.dismiss(context) - } - val alertSender = AlertMessageSender(context) - // Step ordering note: the async location step is kicked off LAST so that - // all synchronous steps (SMS, call) and the "LastAlertAt" write are - // guaranteed to have run before its callback can invoke stopService(). - // Earlier versions ran the call step after kicking off location, which - // created a race where a cached-fix callback could stop the service - // before the call was placed. - - // ---- Step 1: Send SMS alert messages ---- - if (!isStepComplete(STEP_SMS_SENT)) { - alertSender.sendAlertMessage() - markStepComplete(STEP_SMS_SENT) - DebugLogger.d("sendAlert", "SMS step complete") - } else { - DebugLogger.d("sendAlert", "SMS step already complete, skipping") - } - - // ---- Step 2: Phone call ---- - // Done synchronously before the async location step so a fast - // location callback can't stop the service before the call is placed. - if (!isStepComplete(STEP_CALL_MADE)) { - makeAlertCall(context) - markStepComplete(STEP_CALL_MADE) - DebugLogger.d("sendAlert", "Call step complete") - } else { - DebugLogger.d("sendAlert", "Call step already complete, skipping") - } - - // update prefs to include when the alert was sent; not actually used for anything - prefs.edit(commit = true) { - putLong("LastAlertAt", System.currentTimeMillis()) + val steps = object : AlertStepOps { + override fun isComplete(step: Int): Boolean = isStepComplete(step) + override fun markComplete(step: Int) = markStepComplete(step) } - val locationNeeded = prefs.getBoolean("location_enabled", false) || - prefs.getBoolean("webhook_location_enabled", false) - val webhookEnabled = prefs.getBoolean("webhook_enabled", false) - - // Track whether we kicked off an async location request so we know - // whether to stop the service at the end or let the callback do it. - var asyncLocationPending = false - - // ---- Steps 3 & 4: Location SMS + Webhook ---- - // These are grouped because the webhook may need the location result. - // The location request is async; its callback will mark the steps and - // stop the service. If the process dies mid-location, the bits stay - // unset and will be retried on the next delivery. - if (locationNeeded) { - val needLocationSms = !isStepComplete(STEP_LOCATION_DONE) - val needWebhook = webhookEnabled && !isStepComplete(STEP_WEBHOOK_DONE) - - if (needLocationSms || needWebhook) { - try { - asyncLocationPending = true - - val locationHelper = LocationHelper(context) { _, locationResult -> - - // send location SMS if not already sent - if (!isStepComplete(STEP_LOCATION_DONE)) { - alertSender.sendLocationAlertMessage(locationResult.formattedLocationString) - markStepComplete(STEP_LOCATION_DONE) - DebugLogger.d("sendAlert", "Location SMS step complete") - } - - // send webhook with location if not already sent - if (webhookEnabled && !isStepComplete(STEP_WEBHOOK_DONE)) { - sendWebhookRequest(context, locationResult) - markStepComplete(STEP_WEBHOOK_DONE) - DebugLogger.d("sendAlert", "Webhook (with location) step complete") - } else if (!webhookEnabled) { - markStepComplete(STEP_WEBHOOK_DONE) - } - - // stop the service after the async location work is done - stopService() - } - - locationHelper.getLocationAndExecute() - - } catch (e: Exception) { - DebugLogger.d("sendAlert", context.getString(R.string.debug_log_sending_alert_failed), e) - - // mark as done on failure so we don't endlessly retry a broken - // location provider; the core SMS + call already went through - markStepComplete(STEP_LOCATION_DONE) - if (!isStepComplete(STEP_WEBHOOK_DONE)) { - markStepComplete(STEP_WEBHOOK_DONE) - } - asyncLocationPending = false - } - } else { - DebugLogger.d("sendAlert", "Location and webhook steps already complete, skipping") + val dispatcher = object : AlertDispatcher { + override val isUserUnlocked: Boolean get() = isUserUnlocked(context) + override val locationNeeded: Boolean + get() = prefs.getBoolean("location_enabled", false) || + prefs.getBoolean("webhook_location_enabled", false) + override val webhookEnabled: Boolean + get() = prefs.getBoolean("webhook_enabled", false) + + override fun cancelAreYouThereNotification() { + AlertNotificationHelper(context).cancelNotification( + AppController.ARE_YOU_THERE_NOTIFICATION_ID + ) } - } else { - // location not enabled — mark as not needed - markStepComplete(STEP_LOCATION_DONE) - - // send webhook without location if enabled and not already sent - if (webhookEnabled && !isStepComplete(STEP_WEBHOOK_DONE)) { - sendWebhookRequest(context, null) - markStepComplete(STEP_WEBHOOK_DONE) - DebugLogger.d("sendAlert", "Webhook (no location) step complete") - } else { - markStepComplete(STEP_WEBHOOK_DONE) + override fun dismissAreYouThereOverlay() = AreYouThereOverlay.dismiss(context) + override fun sendSmsAlert() = alertSender.sendAlertMessage() + override fun makeCall() = makeAlertCall(context) + override fun writeLastAlertAt(timestamp: Long) { + prefs.edit(commit = true) { putLong("LastAlertAt", timestamp) } + } + override fun requestLocationAsync(onResult: (LocationResult) -> Unit) { + val helper = LocationHelper(context) { _, locationResult -> onResult(locationResult) } + helper.getLocationAndExecute() } + override fun sendLocationSms(locationText: String) = + alertSender.sendLocationAlertMessage(locationText) + override fun sendWebhook(locationResult: LocationResult?) = + sendWebhookRequest(context, locationResult) + override fun stopService() = this@AlertService.stopService() + override fun now(): Long = System.currentTimeMillis() } - // If no async work is pending, stop the service now. - // Otherwise the location callback will stop it when it finishes. - if (!asyncLocationPending) { - stopService() - } + runAlertSteps(steps, dispatcher) } } \ No newline at end of file diff --git a/app/src/main/java/io/keepalive/android/AlertStepRunner.kt b/app/src/main/java/io/keepalive/android/AlertStepRunner.kt new file mode 100644 index 0000000..a170224 --- /dev/null +++ b/app/src/main/java/io/keepalive/android/AlertStepRunner.kt @@ -0,0 +1,162 @@ +package io.keepalive.android + +/** + * Bit flags for the idempotent step tracker used during alert dispatch. + * + * Must stay in sync with [AlertService.ALL_STEPS_COMPLETE] (the service holds + * a private copy for the dedup guard; the two values must agree). + */ +internal const val STEP_SMS_SENT = 1 // SMS messages dispatched to all contacts +internal const val STEP_CALL_MADE = 2 // Phone call placed +internal const val STEP_LOCATION_DONE = 4 // Location SMS sent (or not needed / failed) +internal const val STEP_WEBHOOK_DONE = 8 // Webhook request sent (or not needed / failed) +internal const val ALL_STEPS_COMPLETE = + STEP_SMS_SENT or STEP_CALL_MADE or STEP_LOCATION_DONE or STEP_WEBHOOK_DONE + + +/** Reads / writes the persistent step-completion bitmask for the current alert cycle. */ +interface AlertStepOps { + fun isComplete(step: Int): Boolean + fun markComplete(step: Int) +} + +/** + * All side-effecting operations [runAlertSteps] needs. A production impl lives + * inside [AlertService]; tests pass a fake that records calls. + * + * `locationNeeded` and `webhookEnabled` are read up-front so the sequencing + * logic isn't coupled to preference storage. `now()` is injectable for + * deterministic `LastAlertAt` assertions. + */ +interface AlertDispatcher { + /** Whether the device has been unlocked since boot. */ + val isUserUnlocked: Boolean + + /** + * True if either the location-SMS or the webhook-location-attachment + * feature is enabled. Controls whether the async LocationHelper path runs. + */ + val locationNeeded: Boolean + + /** True if the webhook delivery step is configured. */ + val webhookEnabled: Boolean + + fun cancelAreYouThereNotification() + + /** Called when the user is unlocked; overlays can't display during Direct Boot. */ + fun dismissAreYouThereOverlay() + + fun sendSmsAlert() + fun makeCall() + fun writeLastAlertAt(timestamp: Long) + + /** + * Kicks off an async location request. The callback may run on any thread; + * it must mark the location/webhook steps complete and call [stopService]. + * Throwing from this method is treated as a location-provider failure — + * both location and webhook steps get marked done and the service stops. + */ + fun requestLocationAsync(onResult: (LocationResult) -> Unit) + + fun sendLocationSms(locationText: String) + fun sendWebhook(locationResult: LocationResult?) + + fun stopService() + fun now(): Long +} + + +/** + * Pure sequencing logic for a single alert cycle. + * + * Order is deliberately: + * 1. Cancel the "Are you there?" prompt + overlay (idempotent) + * 2. Send SMS + * 3. Place phone call + * 4. Write `LastAlertAt` (done before the async step so it's guaranteed) + * 5. Location / webhook — async if location needed, sync otherwise + * 6. `stopService` once everything is done (or, in the async path, from the + * location callback) + * + * Each step checks [AlertStepOps.isComplete] first and returns early if done. + * This is how `START_REDELIVER_INTENT` resume works: completed steps are + * skipped, incomplete steps are retried. + * + * Returns **true** if async work is pending (the caller should leave the + * service running; the location callback will stop it). Returns **false** + * if the method already called [AlertDispatcher.stopService]. + */ +internal fun runAlertSteps(steps: AlertStepOps, disp: AlertDispatcher): Boolean { + // Cancel prompt first — these are idempotent and the user has effectively + // "been responded for". + disp.cancelAreYouThereNotification() + if (disp.isUserUnlocked) { + disp.dismissAreYouThereOverlay() + } + + // ---- Step 1: SMS ---- + if (!steps.isComplete(STEP_SMS_SENT)) { + disp.sendSmsAlert() + steps.markComplete(STEP_SMS_SENT) + } + + // ---- Step 2: Phone call ---- + // Done synchronously BEFORE the async location step so a fast location + // callback can't stop the service before the call is placed. + if (!steps.isComplete(STEP_CALL_MADE)) { + disp.makeCall() + steps.markComplete(STEP_CALL_MADE) + } + + // Persisted before any async work so we don't lose it on process death. + disp.writeLastAlertAt(disp.now()) + + val locationNeeded = disp.locationNeeded + val webhookEnabled = disp.webhookEnabled + + // ---- Steps 3 & 4: Location SMS + Webhook ---- + if (locationNeeded) { + val needLocationSms = !steps.isComplete(STEP_LOCATION_DONE) + val needWebhook = webhookEnabled && !steps.isComplete(STEP_WEBHOOK_DONE) + + if (needLocationSms || needWebhook) { + try { + disp.requestLocationAsync { locationResult -> + if (!steps.isComplete(STEP_LOCATION_DONE)) { + disp.sendLocationSms(locationResult.formattedLocationString) + steps.markComplete(STEP_LOCATION_DONE) + } + if (webhookEnabled && !steps.isComplete(STEP_WEBHOOK_DONE)) { + disp.sendWebhook(locationResult) + steps.markComplete(STEP_WEBHOOK_DONE) + } else if (!webhookEnabled) { + steps.markComplete(STEP_WEBHOOK_DONE) + } + disp.stopService() + } + return true // async work pending — caller MUST NOT stop service + } catch (e: Exception) { + // Mark both done on failure so we don't endlessly retry a broken + // location provider; the core SMS + call already went through. + steps.markComplete(STEP_LOCATION_DONE) + if (!steps.isComplete(STEP_WEBHOOK_DONE)) { + steps.markComplete(STEP_WEBHOOK_DONE) + } + // fall through to synchronous stopService below + } + } + // either steps already done or we just marked-on-failure — fall through + } else { + // Location not enabled — mark as not needed and maybe send webhook with no location. + steps.markComplete(STEP_LOCATION_DONE) + if (webhookEnabled && !steps.isComplete(STEP_WEBHOOK_DONE)) { + disp.sendWebhook(null) + steps.markComplete(STEP_WEBHOOK_DONE) + } else { + steps.markComplete(STEP_WEBHOOK_DONE) + } + } + + disp.stopService() + return false +} From d8ddc1dbebb85257fef21a4a771a66602bf559e1 Mon Sep 17 00:00:00 2001 From: keepalivedev Date: Sun, 19 Apr 2026 13:08:15 -0500 Subject: [PATCH 05/26] add more tests --- .../android/AlertNotificationHelperTest.kt | 131 ++++++++ .../keepalive/android/AlertStepRunnerTest.kt | 297 ++++++++++++++++++ .../android/AreYouThereOverlayServiceTest.kt | 153 +++++++++ .../java/io/keepalive/android/SetAlarmTest.kt | 120 +++++++ .../android/WebhookConfigManagerTest.kt | 87 +++++ .../io/keepalive/android/WebhookSenderTest.kt | 257 +++++++++++++++ .../android/testing/FakeAlertDispatcher.kt | 114 +++++++ 7 files changed, 1159 insertions(+) create mode 100644 app/src/test/java/io/keepalive/android/AlertNotificationHelperTest.kt create mode 100644 app/src/test/java/io/keepalive/android/AlertStepRunnerTest.kt create mode 100644 app/src/test/java/io/keepalive/android/AreYouThereOverlayServiceTest.kt create mode 100644 app/src/test/java/io/keepalive/android/SetAlarmTest.kt create mode 100644 app/src/test/java/io/keepalive/android/WebhookConfigManagerTest.kt create mode 100644 app/src/test/java/io/keepalive/android/WebhookSenderTest.kt create mode 100644 app/src/test/java/io/keepalive/android/testing/FakeAlertDispatcher.kt diff --git a/app/src/test/java/io/keepalive/android/AlertNotificationHelperTest.kt b/app/src/test/java/io/keepalive/android/AlertNotificationHelperTest.kt new file mode 100644 index 0000000..e2fe893 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertNotificationHelperTest.kt @@ -0,0 +1,131 @@ +package io.keepalive.android + +import android.Manifest +import android.app.Application +import android.app.Notification +import android.app.NotificationManager +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * Tests [AlertNotificationHelper]: channel creation, dedup, cancel, and + * the "Are you there?" branch that makes the notification ongoing + non-auto-cancel. + */ +@RunWith(RobolectricTestRunner::class) +class AlertNotificationHelperTest { + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + private val nm = appCtx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @Before fun setUp() { + // Robolectric denies POST_NOTIFICATIONS by default on Tiramisu+, which + // makes sendNotification() silently no-op. Grant it for all tests. + shadowOf(appCtx as Application).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + // Clean any notifications from previous tests + nm.cancelAll() + } + + @Test fun `constructor creates all expected channels`() { + AlertNotificationHelper(appCtx) + + val channelIds = nm.notificationChannels.map { it.id }.toSet() + assertTrue(AppController.ARE_YOU_THERE_NOTIFICATION_CHANNEL_ID in channelIds) + assertTrue(AppController.SMS_SENT_NOTIFICATION_CHANNEL_ID in channelIds) + assertTrue(AppController.CALL_SENT_NOTIFICATION_CHANNEL_ID in channelIds) + assertTrue(AppController.ALERT_SERVICE_NOTIFICATION_CHANNEL_ID in channelIds) + } + + @Test fun `sendNotification posts with the right title and text`() { + AlertNotificationHelper(appCtx).sendNotification( + "Alert Title", + "Alert body text", + AppController.SMS_ALERT_SENT_NOTIFICATION_ID + ) + + val active = shadowOf(nm).allNotifications + assertEquals(1, active.size) + val notif = active[0] + val extras = notif.extras + assertEquals("Alert Title", extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()) + assertEquals("Alert body text", extras.getCharSequence(Notification.EXTRA_TEXT)?.toString()) + } + + @Test fun `sendNotification is deduplicated by notification id`() { + val helper = AlertNotificationHelper(appCtx) + helper.sendNotification("T1", "B1", AppController.SMS_ALERT_SENT_NOTIFICATION_ID) + helper.sendNotification("T2", "B2", AppController.SMS_ALERT_SENT_NOTIFICATION_ID) + + val active = shadowOf(nm).allNotifications + assertEquals("second call with the same id should be skipped (overwrite=false default)", + 1, active.size) + assertEquals("T1", active[0].extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()) + } + + @Test fun `sendNotification with overwrite=true replaces existing`() { + val helper = AlertNotificationHelper(appCtx) + helper.sendNotification("T1", "B1", AppController.WEBHOOK_ALERT_SENT_NOTIFICATION_ID) + helper.sendNotification("T2", "B2", AppController.WEBHOOK_ALERT_SENT_NOTIFICATION_ID, overwrite = true) + + val active = shadowOf(nm).allNotifications + assertEquals(1, active.size) + // On API 24+ posting to same id replaces; the latest content wins. + assertEquals("T2", active[0].extras.getCharSequence(Notification.EXTRA_TITLE)?.toString()) + } + + @Test fun `cancelNotification removes an active notification`() { + val helper = AlertNotificationHelper(appCtx) + helper.sendNotification("Title", "Body", AppController.SMS_ALERT_SENT_NOTIFICATION_ID) + assertEquals(1, shadowOf(nm).allNotifications.size) + + helper.cancelNotification(AppController.SMS_ALERT_SENT_NOTIFICATION_ID) + + assertEquals(0, shadowOf(nm).allNotifications.size) + } + + @Test fun `are-you-there notification is ongoing and does not auto-cancel`() { + AlertNotificationHelper(appCtx).sendNotification( + "Are you there?", + "Please respond", + AppController.ARE_YOU_THERE_NOTIFICATION_ID + ) + + val notif = shadowOf(nm).allNotifications.single() + val ongoing = (notif.flags and Notification.FLAG_ONGOING_EVENT) != 0 + val autoCancel = (notif.flags and Notification.FLAG_AUTO_CANCEL) != 0 + assertTrue("are-you-there must be ongoing so it can't be swiped away", ongoing) + assertFalse("are-you-there must NOT auto-cancel on tap; ack is explicit", autoCancel) + } + + @Test fun `non-are-you-there notification auto-cancels on tap`() { + AlertNotificationHelper(appCtx).sendNotification( + "Sent", + "SMS delivered", + AppController.SMS_ALERT_SENT_NOTIFICATION_ID + ) + + val notif = shadowOf(nm).allNotifications.single() + val autoCancel = (notif.flags and Notification.FLAG_AUTO_CANCEL) != 0 + assertTrue("status notifications auto-cancel on tap", autoCancel) + } + + @Test fun `notification has a tappable PendingIntent (contentIntent)`() { + AlertNotificationHelper(appCtx).sendNotification( + "Alert", + "Body", + AppController.SMS_ALERT_SENT_NOTIFICATION_ID + ) + + val notif = shadowOf(nm).allNotifications.single() + assertNotNull("notification must have a contentIntent so user can open the app", + notif.contentIntent) + } +} diff --git a/app/src/test/java/io/keepalive/android/AlertStepRunnerTest.kt b/app/src/test/java/io/keepalive/android/AlertStepRunnerTest.kt new file mode 100644 index 0000000..bfde47b --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertStepRunnerTest.kt @@ -0,0 +1,297 @@ +package io.keepalive.android + +import io.keepalive.android.testing.FakeAlertDispatcher +import io.keepalive.android.testing.FakeAlertStepOps +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for the alert-dispatch sequencer. + * + * Correctness concerns this guards: + * 1. **Step ordering** — SMS must complete before call, call must complete + * before the async location step kicks off. This was a real bug (review + * item #1) where the pre-fix code ran the call AFTER firing the async + * location helper; a fast callback could stop the service mid-flight. + * 2. **Idempotency** — with START_REDELIVER_INTENT, a redelivered intent + * may re-enter sendAlert; completed steps must not re-execute. + * 3. **stopService ownership** — the sync path stops the service at the + * end; the async-location path defers to the callback. Double-stops + * must not happen from the main flow. + */ +class AlertStepRunnerTest { + + private fun sampleLocation() = LocationResult( + latitude = 40.0, + longitude = -70.0, + accuracy = 5f, + geocodedAddress = "1 Infinite Loop", + formattedLocationString = "Lat 40 Lon -70" + ) + + // ======================================================================== + // Step ordering + // ======================================================================== + + @Test fun `no-location path runs cancel, dismiss, SMS, call, writeLastAlertAt, stop in order`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher(isUserUnlocked = true, locationNeeded = false) + + val asyncPending = runAlertSteps(steps, disp) + + assertFalse("sync path: no async work pending", asyncPending) + assertEquals( + listOf( + "cancelNotification", + "dismissOverlay", + "sendSms", + "makeCall", + "writeLastAlertAt", + "stopService" + ), + disp.callLog + ) + } + + @Test fun `call step happens BEFORE async location request`() { + // The critical ordering guarantee. If this fails, the pre-fix race + // is back and a cached location fix could stop the service before + // the phone call dials. + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = true, + webhookEnabled = false, + // Don't auto-fire the callback so we can inspect the state frozen + // at "sendAlert has kicked off location but callback hasn't run". + ).apply { + locationResultToDeliver = null + } + + runAlertSteps(steps, disp) + + // At the point requestLocationAsync was called, SMS and call must + // already have completed. + val smsIdx = disp.callLog.indexOf("sendSms") + val callIdx = disp.callLog.indexOf("makeCall") + val reqIdx = disp.callLog.indexOf("requestLocationAsync") + assertTrue("sms must come first", smsIdx in 0 until callIdx) + assertTrue("call must come before location request", callIdx < reqIdx) + } + + @Test fun `does not dismiss overlay when user is locked (Direct Boot)`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher(isUserUnlocked = false) + + runAlertSteps(steps, disp) + + assertEquals(1, disp.cancelNotificationCount) + assertEquals("overlay service isn't directBootAware — must not try to dismiss", + 0, disp.dismissOverlayCount) + } + + @Test fun `writeLastAlertAt uses the dispatcher clock`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher(nowValue = 12345L) + + runAlertSteps(steps, disp) + + assertEquals(12345L, disp.lastAlertAtWritten) + } + + // ======================================================================== + // Async location path + // ======================================================================== + + @Test fun `returns true when async location is pending`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = true + ).apply { + locationResultToDeliver = null // don't fire the callback + } + + val asyncPending = runAlertSteps(steps, disp) + + assertTrue("async work is pending", asyncPending) + assertEquals("main flow must NOT call stopService — callback owns that", + 0, disp.stopServiceCount) + } + + @Test fun `callback completes location SMS, webhook, and stops the service`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = true, + webhookEnabled = true + ).apply { + locationResultToDeliver = sampleLocation() // auto-fire + } + + runAlertSteps(steps, disp) + + assertEquals(listOf("Lat 40 Lon -70"), disp.locationSmsSends) + assertEquals(1, disp.webhookSends.size) + assertEquals(sampleLocation(), disp.webhookSends[0]) + assertEquals(1, disp.stopServiceCount) + assertTrue(steps.isComplete(STEP_LOCATION_DONE)) + assertTrue(steps.isComplete(STEP_WEBHOOK_DONE)) + } + + @Test fun `location-only mode (no webhook) still marks webhook done to unblock redelivery`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = true, + webhookEnabled = false + ).apply { + locationResultToDeliver = sampleLocation() + } + + runAlertSteps(steps, disp) + + assertEquals(1, disp.locationSmsSends.size) + assertEquals("no webhook calls expected", 0, disp.webhookSends.size) + assertTrue(steps.isComplete(STEP_WEBHOOK_DONE)) + } + + @Test fun `location request exception falls back to sync stop with both steps marked`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = true, + webhookEnabled = true + ).apply { + locationRequestThrows = RuntimeException("location provider died") + } + + val asyncPending = runAlertSteps(steps, disp) + + assertFalse("exception should fall through to sync stop", asyncPending) + assertTrue(steps.isComplete(STEP_LOCATION_DONE)) + assertTrue(steps.isComplete(STEP_WEBHOOK_DONE)) + assertEquals(1, disp.stopServiceCount) + } + + // ======================================================================== + // Webhook-only (no location) path + // ======================================================================== + + @Test fun `webhook only (no location) sends webhook with null location synchronously`() { + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = false, + webhookEnabled = true + ) + + val asyncPending = runAlertSteps(steps, disp) + + assertFalse(asyncPending) + assertEquals(1, disp.webhookSends.size) + assertNull("webhook fires without location when location feature is off", + disp.webhookSends[0]) + assertTrue(steps.isComplete(STEP_LOCATION_DONE)) + assertTrue(steps.isComplete(STEP_WEBHOOK_DONE)) + assertEquals(1, disp.stopServiceCount) + } + + // ======================================================================== + // Idempotency / resume + // ======================================================================== + + @Test fun `SMS step is skipped if already complete`() { + val steps = FakeAlertStepOps(initial = STEP_SMS_SENT) + val disp = FakeAlertDispatcher() + + runAlertSteps(steps, disp) + + assertEquals("SMS must not be re-sent", 0, disp.smsSendCount) + assertEquals(1, disp.callCount) + } + + @Test fun `call step is skipped if already complete`() { + val steps = FakeAlertStepOps(initial = STEP_CALL_MADE) + val disp = FakeAlertDispatcher() + + runAlertSteps(steps, disp) + + assertEquals(1, disp.smsSendCount) + assertEquals("call must not be re-placed", 0, disp.callCount) + } + + @Test fun `fully completed cycle still runs cancel, dismiss, writeLastAlertAt, stop`() { + // START_REDELIVER_INTENT dedup is handled by AlertService.onStartCommand + // (AlertIntentAction), not by this sequencer. But if somehow all steps + // are marked already, we should still cleanly finish — the steps no-op, + // writeLastAlertAt updates timestamp, stopService runs. + val steps = FakeAlertStepOps(initial = ALL_STEPS_COMPLETE) + val disp = FakeAlertDispatcher(isUserUnlocked = true) + + runAlertSteps(steps, disp) + + assertEquals(0, disp.smsSendCount) + assertEquals(0, disp.callCount) + assertEquals(0, disp.webhookSends.size) + assertEquals(1, disp.cancelNotificationCount) + assertEquals(1, disp.dismissOverlayCount) + assertEquals(1, disp.stopServiceCount) + } + + @Test fun `resume after SMS sent but before call also skips location if already complete`() { + // Worst-case resume: SMS went through, process died before call. + // On redelivery we send only call + location + webhook. + val steps = FakeAlertStepOps(initial = STEP_SMS_SENT or STEP_LOCATION_DONE or STEP_WEBHOOK_DONE) + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = true, + webhookEnabled = true + ).apply { + locationResultToDeliver = sampleLocation() + } + + val asyncPending = runAlertSteps(steps, disp) + + assertEquals("SMS skipped", 0, disp.smsSendCount) + assertEquals("call runs", 1, disp.callCount) + // Location and webhook already marked done — async flow should short-circuit + // back to sync stop. + assertFalse("no async work since location+webhook already done", asyncPending) + assertEquals(0, disp.locationSmsSends.size) + assertEquals(0, disp.webhookSends.size) + assertEquals(1, disp.stopServiceCount) + } + + // ======================================================================== + // Callback thread-of-execution + // ======================================================================== + + @Test fun `async callback fired later still completes correctly`() { + // Simulate a slow location provider: kick off sendAlert, then fire + // the callback after main flow has returned. + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher( + isUserUnlocked = true, + locationNeeded = true, + webhookEnabled = true + ).apply { + locationResultToDeliver = null + } + + val asyncPending = runAlertSteps(steps, disp) + assertTrue(asyncPending) + // stopService NOT yet called + assertEquals(0, disp.stopServiceCount) + + // Fire the callback that was captured + disp.pendingLocationCallback!!(sampleLocation()) + + assertTrue(steps.isComplete(STEP_LOCATION_DONE)) + assertTrue(steps.isComplete(STEP_WEBHOOK_DONE)) + assertEquals(1, disp.stopServiceCount) + } +} diff --git a/app/src/test/java/io/keepalive/android/AreYouThereOverlayServiceTest.kt b/app/src/test/java/io/keepalive/android/AreYouThereOverlayServiceTest.kt new file mode 100644 index 0000000..586ff55 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AreYouThereOverlayServiceTest.kt @@ -0,0 +1,153 @@ +package io.keepalive.android + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.view.View +import android.widget.Button +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import android.provider.Settings +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * Tests [AreYouThereOverlayService]. Verifies the static entry points, and + * drives the service lifecycle to confirm the I'm-OK button delegates to + * [AcknowledgeAreYouThere] and the dismiss action does not. + * + * Reads the overlay view via reflection on the private `overlayView` field + * (no public accessor; the production code never needs one). + */ +@RunWith(RobolectricTestRunner::class) +class AreYouThereOverlayServiceTest { + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + + @Before fun setUp() { + mockkObject(AcknowledgeAreYouThere) + every { AcknowledgeAreYouThere.acknowledge(any()) } returns Unit + // Robolectric's Settings.canDrawOverlays defaults to false. The service + // short-circuits in that case. Force-grant via mockkStatic. + mockkStatic(Settings::class) + every { Settings.canDrawOverlays(any()) } returns true + } + + @After fun tearDown() { + unmockkStatic(Settings::class) + unmockkObject(AcknowledgeAreYouThere) + // Drain anything static show() queued so other tests see a clean state. + val shadow = shadowOf(appCtx as Application) + while (shadow.nextStartedService != null) { /* peek-and-pop */ } + } + + private fun showIntent() = Intent(appCtx, AreYouThereOverlayService::class.java).apply { + action = AreYouThereOverlayService.ACTION_SHOW + putExtra(AreYouThereOverlayService.EXTRA_MESSAGE, "test message") + } + + private fun dismissIntent() = Intent(appCtx, AreYouThereOverlayService::class.java).apply { + action = AreYouThereOverlayService.ACTION_DISMISS + } + + /** Reads the private `overlayView` field — no public accessor exists. */ + private fun overlayViewOf(service: AreYouThereOverlayService): View? { + val field = AreYouThereOverlayService::class.java.getDeclaredField("overlayView") + field.isAccessible = true + return field.get(service) as View? + } + + @Test fun `show action inflates an overlay view`() { + val service = Robolectric.buildService(AreYouThereOverlayService::class.java, showIntent()) + .create() + .startCommand(0, 1) + .get() + + assertNotNull("overlayView should be populated after ACTION_SHOW", + overlayViewOf(service)) + } + + @Test fun `I'm OK button invokes acknowledge and clears the overlay reference`() { + val service = Robolectric.buildService(AreYouThereOverlayService::class.java, showIntent()) + .create() + .startCommand(0, 1) + .get() + + val overlay = overlayViewOf(service)!! + overlay.findViewById