diff --git a/.github/workflows/generate-apk-aab-debug-release.yml b/.github/workflows/generate-apk-aab-debug-release.yml index ac04d69..f74b832 100644 --- a/.github/workflows/generate-apk-aab-debug-release.yml +++ b/.github/workflows/generate-apk-aab-debug-release.yml @@ -28,7 +28,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'zulu' # See 'Supported distributions' for available options - java-version: '17' + java-version: '21' cache: 'gradle' - name: Change wrapper permissions diff --git a/.github/workflows/instrumented-tests.yml b/.github/workflows/instrumented-tests.yml new file mode 100644 index 0000000..3864c0a --- /dev/null +++ b/.github/workflows/instrumented-tests.yml @@ -0,0 +1,93 @@ +name: Instrumented tests + +# Instrumented tests need an Android emulator; they're split into a separate +# workflow from the unit tests so a unit-test-only CI cycle stays fast. +# +# Uses the `reactivecircus/android-emulator-runner` action which boots an AVD +# in-CI. Runs all behavioral tests (AlarmFlow, BootFlow, AlertService, +# Acknowledge, SettingsPersistence) — skips the screenshot flow which is +# handled by Fastlane. + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + instrumented-tests: + runs-on: ubuntu-latest + # AVD boot + Robolectric-free tests can take 10–15 min on free runners. + timeout-minutes: 30 + + strategy: + # Don't cancel siblings if one API fails — we want to see every break. + fail-fast: false + matrix: + # API levels span the version-sensitive branches in the codebase: + # 22 — minSdk; pre-M alarm scheduling, no notification channels, + # no runtime permissions, no Direct Boot + # 28 — P; keyguard-based activity detection enables + # 33 — T; POST_NOTIFICATIONS runtime perm, Geocoder async, FOREGROUND_SERVICE_DATA_SYNC + # 34 — U; FOREGROUND_SERVICE_TYPE_SHORT_SERVICE required on AlertService + # 35 — V; current targetSdk + api-level: [22, 28, 33, 34, 35] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '21' + cache: 'gradle' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run instrumented tests (skipping screenshot flow) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + # google_apis gives us Play Services on-emulator for the + # googlePlay flavor's FusedLocationProviderClient. `default` is + # the only reliable image for older levels (API 22). + target: ${{ matrix.api-level < 28 && 'default' || 'google_apis' }} + arch: x86_64 + # No `profile:` set — device profile availability depends on the + # cmdline-tools version on the runner, NOT just the API level. + # We hit "No device found matching --device pixel_8" on API 22 + # AND on API 33+, suggesting pixel_8 isn't in whatever cmdline-tools + # ships with the runner currently. Letting the runner pick its + # default device avoids this whole class of mismatch. + disable-animations: true + # The script runs after the AVD is created and the emulator booted. + # Logging `avdmanager list devices` first surfaces which device + # profiles are actually available on this runner's cmdline-tools, + # so if we ever want to set `profile:` again we know what's safe. + # + # NOTE: emulator-runner runs EACH LINE as its own `sh -c` call. + # That means line continuations (`\`) don't work — the gradle + # invocation must fit on one physical line. + script: | + echo "=== Available device profiles on API ${{ matrix.api-level }} ===" + avdmanager list devices -c | sort -u | head -40 + echo "===" + ./gradlew connectedGooglePlayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.notClass=io.keepalive.android.AppScreenshotsInstrumentedTest --no-daemon + + - name: Upload instrumented test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: instrumented-test-report-api${{ matrix.api-level }} + path: | + app/build/reports/androidTests/ + app/build/outputs/androidTest-results/ diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..8a3e63d --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,40 @@ +name: Unit tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '21' + cache: 'gradle' + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run unit tests (googlePlay + fDroid flavors) + # fDroid flavor covers the android.location-based LocationHelper; + # googlePlay covers the FusedLocationProviderClient path. Both need + # to pass before a merge. + run: ./gradlew testGooglePlayDebugUnitTest testFDroidDebugUnitTest --no-daemon + + - name: Upload test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: unit-test-report + path: | + app/build/reports/tests/ + app/build/test-results/ diff --git a/app/build.gradle b/app/build.gradle index 0008971..813d8ba 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,14 +1,14 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' } kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) - } + // Pins the JDK Gradle uses to build/test. With this set, devs don't + // need JAVA_HOME pointed at JDK 21 — Gradle auto-discovers an installed + // JDK 21 (Windows: C:\Program Files\Java\jdk-21) or fetches one. + // CI workflows use JDK 21 too; this keeps local + CI consistent. + jvmToolchain(21) } android { @@ -51,13 +51,20 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility JavaVersion.VERSION_21 + targetCompatibility JavaVersion.VERSION_21 } buildFeatures { 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" @@ -100,7 +107,7 @@ android { dependencies { - implementation 'com.squareup.okhttp3:okhttp:5.1.0' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' // next versions require compile sdk 36; can't upgrade further because they've dropped // support for API 22 @@ -131,6 +138,22 @@ 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' + + // MockWebServer for WebhookSender HTTP tests. Version matches okhttp above. + testImplementation 'com.squareup.okhttp3:mockwebserver:5.3.2' + // android instrumented test dependencies androidTestImplementation 'androidx.test.ext:junit:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0' @@ -138,5 +161,5 @@ dependencies { androidTestImplementation 'org.mockito:mockito-android:5.23.0' androidTestImplementation 'androidx.test.espresso:espresso-intents:3.7.0' androidTestImplementation 'tools.fastlane:screengrab:2.1.1' - //androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0' } \ No newline at end of file diff --git a/app/src/androidTest/java/io/keepalive/android/AcknowledgeFlowInstrumentedTest.kt b/app/src/androidTest/java/io/keepalive/android/AcknowledgeFlowInstrumentedTest.kt new file mode 100644 index 0000000..cabf420 --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/AcknowledgeFlowInstrumentedTest.kt @@ -0,0 +1,104 @@ +package io.keepalive.android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import io.keepalive.android.AlertFlowTestUtil.fireAlarm +import io.keepalive.android.AlertFlowTestUtil.hasNotification +import io.keepalive.android.AlertFlowTestUtil.hasPendingKeepAliveAlarm +import io.keepalive.android.AlertFlowTestUtil.resetToCleanEnabledState +import io.keepalive.android.AlertFlowTestUtil.savedAlarmStage +import io.keepalive.android.AlertFlowTestUtil.targetContext +import io.keepalive.android.AlertFlowTestUtil.waitUntil +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented coverage of [AcknowledgeAreYouThere] — the single code path + * that runs when the user confirms they're OK (via the notification tap, + * the overlay button, or BOOT_COMPLETED after Direct Boot). + */ +@RunWith(AndroidJUnit4::class) +// Uses NotificationManager.getActiveNotifications() (API 23+). +@SdkSuppress(minSdkVersion = android.os.Build.VERSION_CODES.M) +class AcknowledgeFlowInstrumentedTest { + + companion object { + @JvmStatic + @BeforeClass + fun grantAllPermissions() { + TestSetupUtil.setupTestEnvironment() + } + } + + @Before fun setUp() { + resetToCleanEnabledState() + } + + @After fun tearDown() { + AlertFlowTestUtil.cancelAnyPendingAlarms() + AlertFlowTestUtil.cancelAllNotifications() + } + + @Test fun acknowledgingFromTheAreYouThereStateResetsToPeriodic() { + // Drive the app to a final-alarm-scheduled state. + fireAlarm("periodic") + assertTrue("prompt posted", + waitUntil { hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) }) + assertEquals("stage should be 'final' while awaiting response", + "final", savedAlarmStage()) + + // User taps I'm OK. + AcknowledgeAreYouThere.acknowledge(targetContext) + + // Prompt is gone. + assertTrue("prompt cleared", + waitUntil { !hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) }) + // Periodic alarm re-scheduled. + assertTrue("a fresh periodic alarm should be set", + hasPendingKeepAliveAlarm()) + assertEquals("stage reset to periodic", "periodic", savedAlarmStage()) + } + + @Test fun acknowledgeRecordsActivityTimestampForDirectBootRace() { + val before = System.currentTimeMillis() + Thread.sleep(5) + + AcknowledgeAreYouThere.acknowledge(targetContext) + + val saved = getDeviceProtectedPreferences(targetContext) + .getLong("last_activity_timestamp", -1L) + assertTrue("last_activity_timestamp must be recent so the Direct Boot " + + "final-alarm branch can detect this ack — was $saved, expected >= $before", + saved >= before) + } + + @Test fun acknowledgeClearsTheDirectBootPendingFlag() { + getDeviceProtectedPreferences(targetContext).edit() + .putBoolean("direct_boot_notification_pending", true) + .commit() + + AcknowledgeAreYouThere.acknowledge(targetContext) + + assertFalse("flag must be cleared after acknowledge", + getDeviceProtectedPreferences(targetContext) + .getBoolean("direct_boot_notification_pending", true)) + } + + @Test fun rapidAcknowledgeIsIdempotent() { + fireAlarm("periodic") + waitUntil { hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) } + + // Simulate a user mashing the button — should still end up in a clean + // state without alarm stacking or notification re-appearing. + repeat(5) { AcknowledgeAreYouThere.acknowledge(targetContext) } + + assertFalse(hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID)) + assertTrue("periodic alarm still scheduled", hasPendingKeepAliveAlarm()) + } +} diff --git a/app/src/androidTest/java/io/keepalive/android/AlarmFlowInstrumentedTest.kt b/app/src/androidTest/java/io/keepalive/android/AlarmFlowInstrumentedTest.kt new file mode 100644 index 0000000..42004b2 --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/AlarmFlowInstrumentedTest.kt @@ -0,0 +1,188 @@ +package io.keepalive.android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import io.keepalive.android.AlertFlowTestUtil.FAKE_CONTACT_A +import io.keepalive.android.AlertFlowTestUtil.fireAlarm +import io.keepalive.android.AlertFlowTestUtil.hasNotification +import io.keepalive.android.AlertFlowTestUtil.hasPendingKeepAliveAlarm +import io.keepalive.android.AlertFlowTestUtil.resetToCleanEnabledState +import io.keepalive.android.AlertFlowTestUtil.savedAlarmStage +import io.keepalive.android.AlertFlowTestUtil.targetContext +import io.keepalive.android.AlertFlowTestUtil.waitUntil +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.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * **Instrumented** (real device / emulator) tests for the alert flow. + * + * These don't mock out `SmsManager` / `AlarmManager` / `NotificationManager` — + * they exercise the full stack. The only safety rail is that the fake contact + * numbers are IANA-reserved for fiction (555-01xx) and will fail to deliver + * even if the device has a SIM. + * + * Run with: + * ./gradlew connectedGooglePlayDebugAndroidTest \ + * -Pandroid.testInstrumentationRunnerArguments.class=io.keepalive.android.AlarmFlowInstrumentedTest + * + * Requires: device or emulator connected, network permissions granted + * (handled by [TestSetupUtil.setupTestEnvironment]). + */ +@RunWith(AndroidJUnit4::class) +// Many assertions read the active notification list via +// NotificationManager.getActiveNotifications() — which was added in API 23. +// Pre-M (API 22) the test runner skips this class entirely. +@SdkSuppress(minSdkVersion = android.os.Build.VERSION_CODES.M) +class AlarmFlowInstrumentedTest { + + companion object { + @JvmStatic + @BeforeClass + fun grantAllPermissions() { + TestSetupUtil.setupTestEnvironment() + } + } + + @Before fun setUp() { + // Ensure no AlertService from a prior test is still running and + // tailing writes that would race with ours. + targetContext.stopService(android.content.Intent( + targetContext, AlertService::class.java)) + Thread.sleep(300) + resetToCleanEnabledState() + } + + @After fun tearDown() { + AlertFlowTestUtil.cancelAnyPendingAlarms() + AlertFlowTestUtil.cancelAllNotifications() + targetContext.stopService(android.content.Intent( + targetContext, AlertService::class.java)) + // finalAlarmWithNoActivityStartsAlertService drives the real call + // step against the fake +15550102 number — hang up so the dialer + // doesn't stay foreground after the test process exits. + AlertFlowTestUtil.endAnyActiveCall() + } + + // ---- The core "no recent activity → Are you there? → final" flow ------ + + @Test fun periodicAlarmWithNoActivityPostsAreYouThereAndSchedulesFinal() { + // Fire the periodic alarm as AlarmManager would. + fireAlarm("periodic") + + // The decision engine is synchronous up to the notification post. + // Give the system a beat to actually surface the notification. + assertTrue("Are-you-there notification should appear", + waitUntil { hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) }) + + // And the final alarm must be scheduled. + assertTrue("final alarm must be scheduled", hasPendingKeepAliveAlarm()) + assertEquals("stage saved for Direct Boot recovery", + "final", savedAlarmStage()) + } + + @Test fun finalAlarmWithNoActivityStartsAlertService() { + // Set up as if the "Are you there?" already happened and the followup + // window elapsed without user activity. + fireAlarm("periodic") + waitUntil { hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) } + + // Now simulate the final alarm firing, with no activity in between. + // dispatchFinalAlert calls startForegroundService(AlertService) which + // contractually obligates the OS to expect Service.startForeground() + // within ~5 seconds. We assert via the side effect that + // dispatchFinalAlert writes AFTER attempting to start the service: + // - last_alarm_stage gets reset to "periodic" + // (the full AlertService internals are covered by + // AlertServiceInstrumentedTest, which drives the service directly + // from the instrumentation context). + fireAlarm("final") + + assertTrue( + "dispatchFinalAlert must reset last_alarm_stage to 'periodic' " + + "after attempting to start AlertService", + waitUntil(timeoutMs = 5_000L) { + AlertFlowTestUtil.savedAlarmStage() == "periodic" + } + ) + + // CRITICAL teardown: AlertService was just queued to start as a + // foreground service. If the test process exits while the service is + // still in its "waiting for startForeground()" 5s window, Android + // delivers ForegroundServiceDidNotStartInTimeException to the NEXT + // instrumentation invocation — taking down whichever test runs after + // this one. Wait for AlertService to honor the contract, then stop + // it cleanly. waitForIdleSync gives onStartCommand a chance to run. + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .waitForIdleSync() + Thread.sleep(1_000) + targetContext.stopService( + android.content.Intent(targetContext, AlertService::class.java) + ) + Thread.sleep(500) + } + + @Test fun disabledAppDoesNothingOnAlarm() { + // Disable the app but leave prefs otherwise intact. + getEncryptedSharedPreferences(targetContext).edit() + .putBoolean("enabled", false).commit() + + fireAlarm("periodic") + Thread.sleep(500) + + assertFalse("no notification should appear when app is disabled", + hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID)) + } + + @Test fun staleFinalAlarmDowngradesToPeriodic() { + // A final alarm that fired much later than scheduled (e.g., device + // was off) should be treated as periodic — we prompt the user again + // instead of immediately sending the alert. + val longAgo = System.currentTimeMillis() - 24L * 60 * 60 * 1000 + + fireAlarm("final", alarmTimestamp = longAgo) + + // Downgrade means doAlertCheck("periodic") ran. Most reliable + // evidence: a NEW final alarm was scheduled (the saved stage flips + // to "final" because periodic-due → schedule final). Asserting on + // notification visibility is flaky on a busy emulator — the system + // can take seconds to surface a post. + assertTrue("downgrade should schedule a fresh final alarm", + waitUntil(timeoutMs = 10_000L) { savedAlarmStage() == "final" }) + // Confirm the AlertService wasn't started — a true "final" stage + // would have led to dispatching the real alert. + assertFalse("must NOT have started AlertService", + hasNotification(AppController.ALERT_SERVICE_NOTIFICATION_ID)) + } + + // ---- Prefs integration ----------------------------------------------- + + @Test fun deviceProtectedPrefsAreUpdatedOnEveryAlarmCycle() { + val beforeStamp = System.currentTimeMillis() + Thread.sleep(5) + + fireAlarm("periodic") + waitUntil { hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) } + + val devPrefs = getDeviceProtectedPreferences(targetContext) + val savedCheck = devPrefs.getLong("last_check_timestamp", 0L) + assertTrue("last_check_timestamp should be written on each cycle, got $savedCheck", + savedCheck >= beforeStamp) + } + + @Test fun seedingAnEnabledContactMakesItTheAlertTarget() { + // Sanity check: the fake contact we seeded is what the alert code reads. + val contacts: MutableList = loadJSONSharedPreference( + getEncryptedSharedPreferences(targetContext), "PHONE_NUMBER_SETTINGS" + ) + assertEquals(1, contacts.size) + assertEquals(FAKE_CONTACT_A, contacts[0].phoneNumber) + assertEquals(true, contacts[0].isEnabled) + } +} diff --git a/app/src/androidTest/java/io/keepalive/android/AlertFlowTestUtil.kt b/app/src/androidTest/java/io/keepalive/android/AlertFlowTestUtil.kt new file mode 100644 index 0000000..17f1ab7 --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/AlertFlowTestUtil.kt @@ -0,0 +1,217 @@ +package io.keepalive.android + +import android.app.AlarmManager +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import androidx.test.platform.app.InstrumentationRegistry +import com.google.gson.Gson +import io.keepalive.android.receivers.AlarmReceiver +import java.io.BufferedReader +import java.io.FileInputStream +import java.io.InputStreamReader + +/** + * Helpers for instrumented tests that drive the real alert flow on a + * device/emulator. Designed so each test can set up a deterministic starting + * state, fire the real system intents we react to, and assert on observable + * outcomes (SharedPreferences, NotificationManager, AlarmManager). + * + * **None of these helpers send real SMS or place real calls.** The contact + * numbers we seed (`+1-555-01xx`) are IANA-reserved for fiction and will fail + * to deliver even if the device has a SIM — safe under instrumented CI. + */ +object AlertFlowTestUtil { + + /** Reserved-for-fiction US numbers — safe in tests. */ + const val FAKE_CONTACT_A = "+15550100" + const val FAKE_CONTACT_B = "+15550101" + const val FAKE_CALL_TARGET = "+15550102" + + private val gson = Gson() + + /** Instrumentation context (the test APK). Prefs, services, etc. use targetContext. */ + val targetContext: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + /** + * Reset the app into a clean, enabled state with a single fake SMS contact + * and a fake call target. Clears all pending alarms and notifications. + */ + fun resetToCleanEnabledState( + checkPeriodHours: String = "12", + followupMinutes: String = "60", + includeCallTarget: Boolean = true, + includeSmsContact: Boolean = true + ) { + val ctx = targetContext + val prefs = getEncryptedSharedPreferences(ctx) + val contacts = if (includeSmsContact) { + mutableListOf( + SMSEmergencyContactSetting( + phoneNumber = FAKE_CONTACT_A, + alertMessage = "TEST ALERT — please ignore", + isEnabled = true, + includeLocation = false + ) + ) + } else mutableListOf() + + // Seed APPS_TO_MONITOR with a non-existent package. This takes + // getLastDeviceActivity off the default "keyguard events for 'android'" + // path — which finds real activity on a running emulator — and onto + // the "MOVE_TO_FOREGROUND for these apps" path. Since the fake app + // has never run, the query returns null = "no activity detected", + // which is what we need to observe the prompt → final flow. + val monitoredApps = mutableListOf( + MonitoredAppDetails( + packageName = "com.fake.keepalive.test.monitored", + appName = "Test Monitored App", + lastUsed = 0L, + className = "" + ) + ) + + // Clear device-protected state FIRST so that on pre-N — where + // getDeviceProtectedPreferences() falls back to the same default-prefs + // file as getEncryptedSharedPreferences() — we don't wipe out the + // credential-store writes we're about to make. + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + try { + getDeviceProtectedPreferences(ctx).edit().clear().commit() + } catch (_: Exception) { /* device-protected unavailable: no-op */ } + } + + prefs.edit().apply { + clear() + putBoolean("enabled", true) + putString("time_period_hours", checkPeriodHours) + putString("followup_time_period_minutes", followupMinutes) + putString("PHONE_NUMBER_SETTINGS", gson.toJson(contacts)) + putString("APPS_TO_MONITOR", gson.toJson(monitoredApps)) + if (includeCallTarget) putString("contact_phone", FAKE_CALL_TARGET) + // Don't turn on location/webhook in default tests — keeps the + // alert flow synchronous so assertions are deterministic. + putBoolean("location_enabled", false) + putBoolean("webhook_enabled", false) + putBoolean("webhook_location_enabled", false) + commit() + } + + cancelAnyPendingAlarms() + cancelAllNotifications() + + // Revoke the overlay permission so doAlertCheck's overlay branch + // returns early. Background-FG-service starts are restricted on + // API 31+ and trigger ForegroundServiceDidNotStartInTimeException + // when our tests invoke AlarmReceiver.onReceive() directly (real + // alarm broadcasts grant an exemption; our synthetic invocations + // don't). Tests verify the alert flow via the prompt notification + // and prefs — the overlay is supplementary and doesn't need to + // succeed for the assertions to be meaningful. + try { + shell("appops set ${ctx.packageName} SYSTEM_ALERT_WINDOW deny") + } catch (e: Exception) { /* pre-M / shell-blocked: no-op */ } + } + + /** Cancel the KeepAlive AlarmManager alarm so tests start clean. */ + fun cancelAnyPendingAlarms() { + cancelAlarm(targetContext) + } + + /** Clear all notifications the app has posted. */ + fun cancelAllNotifications() { + val nm = targetContext.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + nm.cancelAll() + } + + /** + * End any in-progress phone call. + * + * `AlertService` issues a real `Intent.ACTION_CALL` to the seeded fake + * number ([FAKE_CALL_TARGET]). On an emulator with no SIM that placement + * fails — but the dialer UI still surfaces and, depending on the API + * level, the call state can stay "active" (or the dialer activity can + * stay foreground) after the test process is torn down. Send + * `KEYCODE_ENDCALL` (6) to hang up; portable across every API the app + * supports (`cmd telecom end-call` doesn't exist pre-N). + */ + fun endAnyActiveCall() { + try { + shell("input keyevent 6") + } catch (_: Exception) { /* shell unavailable: no-op */ } + } + + /** Drive the periodic or final alarm by delivering the intent to the receiver directly. */ + fun fireAlarm(alarmStage: String, alarmTimestamp: Long = System.currentTimeMillis()) { + val intent = Intent(targetContext, AlarmReceiver::class.java).apply { + putExtra("AlarmStage", alarmStage) + putExtra("AlarmTimestamp", alarmTimestamp) + } + AlarmReceiver().onReceive(targetContext, intent) + } + + /** Returns whether any KeepAlive alarm is currently scheduled in the system AlarmManager. */ + fun hasPendingKeepAliveAlarm(): Boolean { + val am = targetContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager + // The cleanest check is via dumpsys; on an emulator we can just look + // at next-alarm-clock. Both have caveats. Use NextAlarmTimestamp the + // app itself persists — if the app thinks an alarm is scheduled and + // we haven't cancelled it, it's really scheduled. + val saved = getEncryptedSharedPreferences(targetContext) + .getLong("NextAlarmTimestamp", 0L) + return saved > 0L + } + + /** Count notifications currently posted by the app. */ + fun activeNotificationCount(): Int { + val nm = targetContext.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + return nm.activeNotifications.size + } + + /** Check whether a notification with the given ID is currently visible. */ + fun hasNotification(id: Int): Boolean { + val nm = targetContext.getSystemService(Context.NOTIFICATION_SERVICE) + as NotificationManager + return nm.activeNotifications.any { it.id == id } + } + + /** Wait up to [timeoutMs] for [predicate] to return true, polling every [pollMs]. */ + fun waitUntil(timeoutMs: Long = 5000L, pollMs: Long = 100L, predicate: () -> Boolean): Boolean { + val end = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < end) { + if (predicate()) return true + Thread.sleep(pollMs) + } + return predicate() + } + + /** Run an `adb shell`-equivalent command inside the instrumentation. */ + fun shell(cmd: String): String { + val fd = InstrumentationRegistry.getInstrumentation() + .uiAutomation.executeShellCommand(cmd) + val buf = StringBuilder() + BufferedReader(InputStreamReader(FileInputStream(fd.fileDescriptor))).use { reader -> + reader.forEachLine { buf.appendLine(it) } + } + fd.close() + return buf.toString() + } + + /** Read a step-tracker bit to confirm the alert service reached a given stage. */ + fun isAlertStepComplete(stepBit: Int): Boolean { + val saved = getEncryptedSharedPreferences(targetContext) + .getInt("AlertStepsCompleted", 0) + return (saved and stepBit) == stepBit + } + + fun savedAlertTriggerTimestamp(): Long = + getEncryptedSharedPreferences(targetContext).getLong("AlertTriggerTimestamp", 0L) + + fun savedAlarmStage(): String? = + try { + getDeviceProtectedPreferences(targetContext).getString("last_alarm_stage", null) + } catch (e: Exception) { null } +} diff --git a/app/src/androidTest/java/io/keepalive/android/AlertServiceInstrumentedTest.kt b/app/src/androidTest/java/io/keepalive/android/AlertServiceInstrumentedTest.kt new file mode 100644 index 0000000..aa7105e --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/AlertServiceInstrumentedTest.kt @@ -0,0 +1,184 @@ +package io.keepalive.android + +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import io.keepalive.android.AlertFlowTestUtil.hasNotification +import io.keepalive.android.AlertFlowTestUtil.resetToCleanEnabledState +import io.keepalive.android.AlertFlowTestUtil.savedAlertTriggerTimestamp +import io.keepalive.android.AlertFlowTestUtil.targetContext +import io.keepalive.android.AlertFlowTestUtil.waitUntil +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented coverage of [AlertService] — dedup guard, foreground lifecycle, + * step tracking, and teardown on real Android rather than Robolectric. + * + * Important: these tests don't assert SMS delivery. They assert that the + * service progressed through the step tracker (SMS bit set, CALL bit set) + * and eventually stops. On a device with a SIM the SMS attempt to the fake + * 555-01xx contact fails at the network layer, which is fine — the step bit + * still flips because we mark-complete after the dispatch call, not after + * delivery confirmation. + */ +@RunWith(AndroidJUnit4::class) +// Uses NotificationManager.getActiveNotifications() (API 23+) and +// AlertService is foreground-aware (API 26+ contract). API 22 skipped. +@SdkSuppress(minSdkVersion = android.os.Build.VERSION_CODES.M) +class AlertServiceInstrumentedTest { + + companion object { + @JvmStatic + @BeforeClass + fun grantAllPermissions() { + TestSetupUtil.setupTestEnvironment() + } + } + + @Before fun setUp() { + // Ensure any AlertService from a prior test has fully stopped before + // we reset state — otherwise its tail-end writes can race with ours. + targetContext.stopService(Intent(targetContext, AlertService::class.java)) + Thread.sleep(500) + + resetToCleanEnabledState() + // Also clear step tracker from any prior run + getEncryptedSharedPreferences(targetContext).edit() + .putLong("AlertTriggerTimestamp", 0L) + .putInt("AlertStepsCompleted", 0) + .putLong("LastAlertAt", 0L) + .commit() + } + + @After fun tearDown() { + AlertFlowTestUtil.cancelAllNotifications() + // Stop the service if still running + targetContext.stopService(Intent(targetContext, AlertService::class.java)) + Thread.sleep(200) + // AlertService dispatches a real Intent.ACTION_CALL to the fake + // +15550102 number; hang up so the dialer doesn't stay foreground. + AlertFlowTestUtil.endAnyActiveCall() + } + + private fun startAlertService(triggerTimestamp: Long = System.currentTimeMillis()) { + val intent = Intent(targetContext, AlertService::class.java).apply { + putExtra(AlertService.EXTRA_ALERT_TRIGGER_TIMESTAMP, triggerTimestamp) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + targetContext.startForegroundService(intent) + } else { + targetContext.startService(intent) + } + } + + @Test fun serviceRunsAllStepsOnNewTrigger() { + val trigger = System.currentTimeMillis() + startAlertService(trigger) + + // Service registers its trigger in prefs before any step runs + assertTrue("trigger persisted", + waitUntil { savedAlertTriggerTimestamp() == trigger }) + + // All four steps should complete (even if SMS delivery fails at the + // network layer — we mark after dispatch, not after delivery). + assertTrue("SMS step", waitUntil { AlertFlowTestUtil.isAlertStepComplete(STEP_SMS_SENT) }) + assertTrue("CALL step", waitUntil { AlertFlowTestUtil.isAlertStepComplete(STEP_CALL_MADE) }) + assertTrue("LOCATION step (skipped since location_enabled=false)", + waitUntil { AlertFlowTestUtil.isAlertStepComplete(STEP_LOCATION_DONE) }) + assertTrue("WEBHOOK step (skipped since webhook_enabled=false)", + waitUntil { AlertFlowTestUtil.isAlertStepComplete(STEP_WEBHOOK_DONE) }) + + // LastAlertAt should be recent + val lastAt = getEncryptedSharedPreferences(targetContext).getLong("LastAlertAt", 0) + assertTrue("LastAlertAt should be recent", lastAt >= trigger) + } + + @Test fun duplicateTriggerDoesNotRerunSteps() { + val trigger = System.currentTimeMillis() + startAlertService(trigger) + // Wait for ALL step bits to be set — the dedup guard checks the whole + // ALL_STEPS_COMPLETE mask. If we send the second intent before + // LOCATION_DONE/WEBHOOK_DONE land, dedup decides Resume (not Skip) + // and re-runs sendAlert, updating LastAlertAt. + assertTrue("first run completes all 4 steps", + waitUntil(timeoutMs = 10_000L) { + AlertFlowTestUtil.isAlertStepComplete(ALL_STEPS_COMPLETE) + }) + assertTrue("first run should write LastAlertAt", + waitUntil { getEncryptedSharedPreferences(targetContext).getLong("LastAlertAt", 0) > 0 }) + + val firstLastAt = getEncryptedSharedPreferences(targetContext).getLong("LastAlertAt", 0) + Thread.sleep(50) + + // Send the same trigger again — dedup guard should bail. + startAlertService(trigger) + Thread.sleep(1500) // let service boot and bail + + val secondLastAt = getEncryptedSharedPreferences(targetContext).getLong("LastAlertAt", 0) + assertEquals("LastAlertAt should NOT be updated by a duplicate trigger", + firstLastAt, secondLastAt) + } + + @Test fun newerTriggerOverwritesTheSavedTrigger() { + val firstTrigger = System.currentTimeMillis() + startAlertService(firstTrigger) + assertTrue(waitUntil { AlertFlowTestUtil.isAlertStepComplete(STEP_SMS_SENT) }) + + val newerTrigger = firstTrigger + 1000 + startAlertService(newerTrigger) + assertTrue("newer trigger must replace the saved one", + waitUntil { savedAlertTriggerTimestamp() == newerTrigger }) + } + + @Test fun staleTriggerIsIgnored() { + val newer = System.currentTimeMillis() + startAlertService(newer) + assertTrue(waitUntil { savedAlertTriggerTimestamp() == newer }) + + val older = newer - 5000 + val savedBefore = savedAlertTriggerTimestamp() + startAlertService(older) + Thread.sleep(1000) + + assertEquals("stale trigger must not overwrite a newer saved trigger", + savedBefore, savedAlertTriggerTimestamp()) + } + + @Test fun serviceEventuallyStopsItself() { + startAlertService() + assertTrue(waitUntil { AlertFlowTestUtil.isAlertStepComplete(STEP_CALL_MADE) }) + + // AlertService stops itself after the sync steps complete (no async + // location in this config). Notification should be cleared within a + // few seconds. + assertTrue("AlertService foreground notification should be cleared after completion", + waitUntil(timeoutMs = 10_000L) { + !hasNotification(AppController.ALERT_SERVICE_NOTIFICATION_ID) + }) + } + + @Test fun serviceRunsWithoutCrashingWhenContactsAreBlank() { + // Edge case: the user enables the app but hasn't configured a contact. + getEncryptedSharedPreferences(targetContext).edit() + .putString("PHONE_NUMBER_SETTINGS", "[]") + .putString("contact_phone", "") + .commit() + + startAlertService() + Thread.sleep(2000) + + // Steps still mark complete — we don't hang when there's nothing to send. + assertTrue(AlertFlowTestUtil.isAlertStepComplete(STEP_SMS_SENT)) + assertTrue(AlertFlowTestUtil.isAlertStepComplete(STEP_CALL_MADE)) + } +} diff --git a/app/src/androidTest/java/io/keepalive/android/BootFlowInstrumentedTest.kt b/app/src/androidTest/java/io/keepalive/android/BootFlowInstrumentedTest.kt new file mode 100644 index 0000000..2791338 --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/BootFlowInstrumentedTest.kt @@ -0,0 +1,120 @@ +package io.keepalive.android + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import io.keepalive.android.AlertFlowTestUtil.hasNotification +import io.keepalive.android.AlertFlowTestUtil.hasPendingKeepAliveAlarm +import io.keepalive.android.AlertFlowTestUtil.resetToCleanEnabledState +import io.keepalive.android.AlertFlowTestUtil.targetContext +import io.keepalive.android.AlertFlowTestUtil.waitUntil +import io.keepalive.android.receivers.BootBroadcastReceiver +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests for [BootBroadcastReceiver]. Simulates boot intents to + * verify the receiver restores alarms and handles Direct Boot acknowledgement + * correctly. True reboot behavior can only be tested manually; this covers + * the receiver logic that runs on every real reboot. + */ +@RunWith(AndroidJUnit4::class) +// Direct Boot, the device-protected pending flag, and the LOCKED_BOOT_COMPLETED +// path are all API N (24)+. Pre-N these tests are testing concepts that +// don't exist on the OS. Also uses getActiveNotifications (API 23+). +@SdkSuppress(minSdkVersion = android.os.Build.VERSION_CODES.N) +class BootFlowInstrumentedTest { + + companion object { + @JvmStatic + @BeforeClass + fun grantAllPermissions() { + TestSetupUtil.setupTestEnvironment() + } + } + + @Before fun setUp() { + resetToCleanEnabledState() + } + + @After fun tearDown() { + AlertFlowTestUtil.cancelAnyPendingAlarms() + AlertFlowTestUtil.cancelAllNotifications() + } + + private fun deliverBoot(action: String) { + val intent = Intent(action) + BootBroadcastReceiver().onReceive(targetContext, intent) + } + + @Test fun bootCompletedWithNoPendingFlagRestoresAlarm() { + // Simulate the state right after a reboot where the app was running + // normally: no Direct Boot prompt was pending. + getDeviceProtectedPreferences(targetContext).edit() + .putBoolean("direct_boot_notification_pending", false) + .putString("last_alarm_stage", "periodic") + .commit() + + deliverBoot(Intent.ACTION_BOOT_COMPLETED) + + // doAlertCheck ran with stage=periodic → schedules a new alarm. + // Either way, app should have an alarm scheduled within a short window. + assertTrue("receiver should schedule an alarm after boot", + waitUntil { hasPendingKeepAliveAlarm() }) + } + + @Test fun bootCompletedWithPendingDirectBootFlagAcknowledges() { + // Simulate: device rebooted while "Are you there?" was pending, then + // the user unlocked → BOOT_COMPLETED fires. Unlock is proof of + // activity. We must NOT re-alert; we must clear the pending flag. + val devPrefs = getDeviceProtectedPreferences(targetContext) + devPrefs.edit() + .putBoolean("direct_boot_notification_pending", true) + .putString("last_alarm_stage", "final") + .commit() + + // Seed an "Are you there?" notification so we can assert it's cancelled. + AlertNotificationHelper(targetContext).sendNotification( + "Are you there?", "Please respond", AppController.ARE_YOU_THERE_NOTIFICATION_ID + ) + // Real NotificationManager has a small delay before active-notifications + // reflects a post. Poll. + assertTrue("seeded notification should be visible", + waitUntil { hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) }) + + deliverBoot(Intent.ACTION_BOOT_COMPLETED) + + assertTrue("pending flag should be cleared", + waitUntil { !devPrefs.getBoolean("direct_boot_notification_pending", true) }) + assertTrue("are-you-there notification should be cancelled by acknowledge", + waitUntil { !hasNotification(AppController.ARE_YOU_THERE_NOTIFICATION_ID) }) + } + + @Test fun lockedBootCompletedWithUnlockedUserIsSkipped() { + // On an app-redeploy, both LOCKED_BOOT_COMPLETED and BOOT_COMPLETED + // fire even though the user is already unlocked. The locked handler + // must bail so we don't double-run doAlertCheck. + // This test can only observe behavior — it doesn't flip the unlock + // state (impossible without a real reboot). It just confirms the + // receiver doesn't crash on a spurious LOCKED intent. + deliverBoot("android.intent.action.LOCKED_BOOT_COMPLETED") + // No assertion — this is a "doesn't-blow-up" test; pairs with the + // unit-test coverage which asserts the branching logic. + } + + @Test fun disabledAppIgnoresBootIntents() { + getEncryptedSharedPreferences(targetContext).edit() + .putBoolean("enabled", false).commit() + + deliverBoot(Intent.ACTION_BOOT_COMPLETED) + Thread.sleep(500) + + assertFalse("no alarm should be scheduled when the app is disabled", + hasPendingKeepAliveAlarm()) + } +} diff --git a/app/src/androidTest/java/io/keepalive/android/MinSdkSmokeInstrumentedTest.kt b/app/src/androidTest/java/io/keepalive/android/MinSdkSmokeInstrumentedTest.kt new file mode 100644 index 0000000..2db339a --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/MinSdkSmokeInstrumentedTest.kt @@ -0,0 +1,237 @@ +package io.keepalive.android + +import android.content.Context +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.keepalive.android.receivers.BootBroadcastReceiver +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Behavioral smoke tests that work on **every API the app supports** — + * including the minSdk floor of API 22. + * + * Why a separate class instead of relaxing `@SdkSuppress` on the existing + * ones? The other instrumented tests assert on + * `NotificationManager.getActiveNotifications()` (added in API 23) and on + * device-protected-storage state (added in API 24). This class deliberately + * limits its assertions to side effects that are observable on every API: + * - SharedPreferences writes (`NextAlarmTimestamp`, `LastAlertAt`, etc.) + * - The implicit AlarmManager state inferred from `NextAlarmTimestamp` + * - The values setAlarm bakes into prefs + * + * That gives us behavioral coverage on API 22 (~5–10% of features — + * scheduling and gating, not the notification half) where otherwise the + * matrix only verifies that the APK installs. + */ +@RunWith(AndroidJUnit4::class) +class MinSdkSmokeInstrumentedTest { + + companion object { + @JvmStatic + @BeforeClass + fun grantAllPermissions() { + TestSetupUtil.setupTestEnvironment() + } + } + + private val ctx: Context = AlertFlowTestUtil.targetContext + private val prefs get() = getEncryptedSharedPreferences(ctx) + + @Before fun setUp() { + AlertFlowTestUtil.resetToCleanEnabledState() + // Make sure NextAlarmTimestamp is unset so each test's assertion has + // an unambiguous baseline. + prefs.edit().remove("NextAlarmTimestamp").commit() + } + + @After fun tearDown() { + AlertFlowTestUtil.cancelAnyPendingAlarms() + AlertFlowTestUtil.cancelAllNotifications() + // finalAlarmWithNoActivityWritesLastAlertAt drives AlertService all + // the way through the call step; if we don't hang up the dialer + // stays foreground after the test process is torn down. + AlertFlowTestUtil.endAnyActiveCall() + } + + // ---- setAlarm primitive ------------------------------------------------ + + @Test fun setAlarmWritesNextAlarmTimestampInTheFuture() { + val before = System.currentTimeMillis() + setAlarm(ctx, before, desiredAlarmInMinutes = 30, alarmStage = "periodic", + restPeriods = null) + + val saved = prefs.getLong("NextAlarmTimestamp", 0) + assertTrue("NextAlarmTimestamp should be set in the future; saved=$saved", + saved > before) + // ~30 min out — allow a wide window for slow emulators + val msFromBefore = saved - before + assertTrue( + "expected ~30 min from now, got msFromBefore=$msFromBefore", + msFromBefore in 25L * 60_000..35L * 60_000 + ) + } + + @Test fun setAlarmAgainOverwritesThePreviousNextAlarmTimestamp() { + val now = System.currentTimeMillis() + setAlarm(ctx, now, 5, "periodic", null) + val first = prefs.getLong("NextAlarmTimestamp", 0) + Thread.sleep(50) + setAlarm(ctx, now, 60, "periodic", null) + val second = prefs.getLong("NextAlarmTimestamp", 0) + assertTrue( + "second setAlarm should produce a later timestamp; first=$first second=$second", + second > first + ) + } + + // ---- AlarmReceiver / doAlertCheck ----- via AlertFlowTestUtil.fireAlarm + + @Test fun firingPeriodicAlarmWithNoActivitySchedulesAFollowupNearTheFollowupPeriod() { + // No activity (fake monitored package never runs) → doAlertCheck + // posts the prompt and schedules a final alarm at now + followup_minutes. + // followup_time_period_minutes = 60 in the default reset state. + val before = System.currentTimeMillis() + AlertFlowTestUtil.fireAlarm("periodic") + + val saved = prefs.getLong("NextAlarmTimestamp", 0) + val msFromBefore = saved - before + assertTrue( + "expected a final-followup alarm ~60 min out; saved=$saved msFromBefore=$msFromBefore", + msFromBefore in 50L * 60_000..70L * 60_000 + ) + } + + @Test fun firingPeriodicAlarmWhenAppDisabledSchedulesNothing() { + // Disable the app and verify the receiver short-circuits cleanly. + prefs.edit().putBoolean("enabled", false).commit() + + AlertFlowTestUtil.fireAlarm("periodic") + + val saved = prefs.getLong("NextAlarmTimestamp", -1L) + assertEquals( + "disabled app must not schedule a follow-up alarm", + -1L, saved + ) + } + + @Test fun staleFinalAlarmDowngradesToPeriodicSchedulesAFollowup() { + // A "final" alarm fired hours after it was scheduled is treated as + // periodic — the user gets prompted again instead of an immediate alert. + // Observable via NextAlarmTimestamp landing in the followup window + // (i.e. final-alarm scheduled), not the much-larger checkPeriod window + // (which would mean a full reschedule with no prompt). + val before = System.currentTimeMillis() + val longAgo = before - 24L * 60 * 60 * 1000 + + AlertFlowTestUtil.fireAlarm("final", alarmTimestamp = longAgo) + + val saved = prefs.getLong("NextAlarmTimestamp", 0) + val msFromBefore = saved - before + assertTrue( + "stale final → downgraded → final-followup ~60min out; got $msFromBefore", + msFromBefore in 50L * 60_000..70L * 60_000 + ) + } + + // ---- BootBroadcastReceiver -------------------------------------------- + + @Test fun bootCompletedWhenEnabledSchedulesAnAlarm() { + val before = System.currentTimeMillis() + BootBroadcastReceiver().onReceive(ctx, Intent(Intent.ACTION_BOOT_COMPLETED)) + + val saved = prefs.getLong("NextAlarmTimestamp", 0) + assertTrue( + "BOOT_COMPLETED + enabled=true should schedule an alarm; saved=$saved", + saved > before + ) + } + + @Test fun bootCompletedWhenDisabledSchedulesNothing() { + prefs.edit().putBoolean("enabled", false).commit() + + BootBroadcastReceiver().onReceive(ctx, Intent(Intent.ACTION_BOOT_COMPLETED)) + + val saved = prefs.getLong("NextAlarmTimestamp", -1L) + assertEquals(-1L, saved) + } + + @Test fun unrelatedBroadcastIsIgnored() { + BootBroadcastReceiver().onReceive(ctx, Intent("io.keepalive.unrelated.action")) + + val saved = prefs.getLong("NextAlarmTimestamp", -1L) + assertEquals("non-boot intents must not schedule an alarm", -1L, saved) + } + + // ---- AcknowledgeAreYouThere ------------------------------------------- + + @Test fun acknowledgeReschedulesAroundTheCheckPeriod() { + // Drive into "prompt active" state — final alarm scheduled ~60min out. + AlertFlowTestUtil.fireAlarm("periodic") + val finalAlarmAt = prefs.getLong("NextAlarmTimestamp", 0) + assertTrue("final alarm should be set", finalAlarmAt > 0) + + // Ack — should rebase to a 12h periodic schedule (default check period). + val before = System.currentTimeMillis() + AcknowledgeAreYouThere.acknowledge(ctx) + + val periodicAt = prefs.getLong("NextAlarmTimestamp", 0) + val msFromBefore = periodicAt - before + assertTrue( + "ack should schedule a 12h periodic alarm; got msFromBefore=$msFromBefore", + msFromBefore in 11L * 60 * 60 * 1000..13L * 60 * 60 * 1000 + ) + } + + @Test fun acknowledgeFromNoActivePromptStillSchedulesPeriodic() { + val before = System.currentTimeMillis() + AcknowledgeAreYouThere.acknowledge(ctx) + + val saved = prefs.getLong("NextAlarmTimestamp", 0) + val msFromBefore = saved - before + assertTrue( + "ack with no active prompt must still schedule a periodic alarm", + msFromBefore > 60L * 60 * 1000 + ) + } + + // ---- LastAlertAt persistence (final alarm with no activity) ----------- + + @Test fun finalAlarmWithNoActivityWritesLastAlertAt() { + // Direct path: "final" stage + no activity → dispatchFinalAlert → + // AlertService.sendAlert sets LastAlertAt. We can't use Robolectric's + // service controller here, but we CAN observe whether the pref got + // written within a reasonable window. + prefs.edit().remove("LastAlertAt").commit() + val before = System.currentTimeMillis() + + AlertFlowTestUtil.fireAlarm("final") + + // The AlertService runs on a background thread; poll briefly. + val ok = AlertFlowTestUtil.waitUntil(timeoutMs = 10_000L) { + prefs.getLong("LastAlertAt", 0) >= before + } + + // Stop AlertService cleanly before the assertion can fail-fast and + // tear down the test process. Same FG-service-contract concern as + // AlarmFlowInstrumentedTest.finalAlarmWithNoActivityStartsAlertService: + // if the process dies while AlertService is still in its + // "waiting for startForeground" 5s window, the OS delivers + // ForegroundServiceDidNotStartInTimeException to the next test. + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation() + .waitForIdleSync() + Thread.sleep(1_000) + ctx.stopService(android.content.Intent(ctx, AlertService::class.java)) + Thread.sleep(500) + + assertTrue( + "final alarm with no activity should record LastAlertAt eventually", + ok + ) + } +} diff --git a/app/src/androidTest/java/io/keepalive/android/OverlayInstrumentedTest.kt b/app/src/androidTest/java/io/keepalive/android/OverlayInstrumentedTest.kt new file mode 100644 index 0000000..b65dbc7 --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/OverlayInstrumentedTest.kt @@ -0,0 +1,187 @@ +package io.keepalive.android + +import android.content.Context +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented end-to-end test for the "Are you there?" overlay on a real + * device, with overlay permission GRANTED. Complements the Robolectric + * [AreYouThereOverlayServiceTest] (which mocks `Settings.canDrawOverlays`) + * and the other instrumented tests (which deny the permission so the + * alert flow doesn't trip the foreground-service contract under + * synthetic-broadcast conditions). + * + * Drives the service directly via `startForegroundService(ACTION_SHOW)` — + * instrumentation context has the foreground-service-start exemption so + * we don't hit the same async crash that synthetic AlarmReceiver + * invocations do. + * + * Uses UI Automator to assert that the overlay window is actually + * rendered, and that tapping "I'm OK" dismisses it. + * + * Gated to API 26+ because: + * - `startForegroundService` is API 26+ + * - UI Automator's `By.res(...)` resource-ID lookup is reliable post-O + * - The overlay's foreground-service infrastructure assumes O+ semantics + */ +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = android.os.Build.VERSION_CODES.O) +class OverlayInstrumentedTest { + + private val targetContext: Context = AlertFlowTestUtil.targetContext + private val device: UiDevice = + UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + companion object { + @JvmStatic + @BeforeClass + fun grantAllPermissions() { + TestSetupUtil.setupTestEnvironment() + } + } + + @Before fun setUp() { + // Other instrumented tests revoke SYSTEM_ALERT_WINDOW so the alert + // flow's overlay branch is bypassed. THIS class needs the permission + // granted — re-grant explicitly. (The class might run after one of + // the revoking classes alphabetically.) + AlertFlowTestUtil.shell( + "appops set ${targetContext.packageName} SYSTEM_ALERT_WINDOW allow" + ) + + // CI emulators idle the screen during a long test run. UiAutomator + // tap injection on TYPE_APPLICATION_OVERLAY windows requires an + // awake screen with the keyguard dismissed; otherwise click() looks + // successful but the touch is never delivered to the overlay + // window. Wake + unlock before each test to make this deterministic. + device.wakeUp() + device.pressMenu() + Thread.sleep(200) + + // Make sure no leftover overlay from a prior test is in the way. + AreYouThereOverlay.dismiss(targetContext) + Thread.sleep(300) + } + + @After fun tearDown() { + // Stop the overlay service so it doesn't leak into the next class. + targetContext.stopService( + Intent(targetContext, AreYouThereOverlayService::class.java) + ) + Thread.sleep(300) + } + + @Test fun overlayAppearsAndDismissesWhenPermissionGranted() { + val intent = Intent(targetContext, AreYouThereOverlayService::class.java).apply { + action = AreYouThereOverlayService.ACTION_SHOW + putExtra(AreYouThereOverlayService.EXTRA_MESSAGE, "test message") + } + + // Direct invocation from the instrumentation context. This path + // hits the real Service.onCreate → startForeground contract and + // the real WindowManager.addView — i.e., the production code path + // that Robolectric can't simulate. + targetContext.startForegroundService(intent) + + val okButton = device.wait( + Until.findObject(By.res(targetContext.packageName, "buttonImOk")), + 5_000L + ) + assertNotNull("overlay should render the I'm OK button", okButton) + assertTrue("I'm OK button should be enabled", okButton.isEnabled) + + // Tap I'm OK — should dismiss the overlay AND invoke + // AcknowledgeAreYouThere.acknowledge() which writes the + // last_activity_timestamp pref (the Direct-Boot race-fix). + // + // UiAutomator click injection on TYPE_APPLICATION_OVERLAY windows is + // best-effort across emulator images: when it works, dismissal is + // instant; when it doesn't, the click() call returns silently and + // the overlay stays. Retry up to 3 times before giving up and + // checking whether the overlay state ever cleared. + val before = System.currentTimeMillis() + var gone = false + repeat(3) { attempt -> + if (gone) return@repeat + okButton.click() + gone = device.wait( + Until.gone(By.res(targetContext.packageName, "buttonImOk")), + 5_000L + ) + if (!gone) { + // Re-find — the previous handle may have been invalidated + // by partial state changes between attempts. + device.wait( + Until.findObject(By.res(targetContext.packageName, "buttonImOk")), + 1_000L + ) + } + } + assertTrue("overlay should disappear after I'm OK click", gone) + + // Acknowledge side effect: last_activity_timestamp persisted. + Thread.sleep(300) + val saved = getDeviceProtectedPreferences(targetContext) + .getLong("last_activity_timestamp", -1L) + assertTrue( + "acknowledge() must record activity timestamp; saved=$saved before=$before", + saved >= before + ) + } + + @Test fun overlayShowsConfiguredMessage() { + val message = "please respond within 60 minutes" + val intent = Intent(targetContext, AreYouThereOverlayService::class.java).apply { + action = AreYouThereOverlayService.ACTION_SHOW + putExtra(AreYouThereOverlayService.EXTRA_MESSAGE, message) + } + targetContext.startForegroundService(intent) + + val messageView = device.wait( + Until.findObject(By.res(targetContext.packageName, "textAreYouThereMessage")), + 5_000L + ) + assertNotNull("overlay should render the message view", messageView) + assertTrue( + "message text must reflect the EXTRA_MESSAGE — got '${messageView.text}'", + messageView.text == message + ) + } + + @Test fun dismissActionRemovesTheOverlayWithoutAcknowledging() { + val show = Intent(targetContext, AreYouThereOverlayService::class.java).apply { + action = AreYouThereOverlayService.ACTION_SHOW + putExtra(AreYouThereOverlayService.EXTRA_MESSAGE, "test") + } + targetContext.startForegroundService(show) + + // Wait for it to be visible, then dismiss via stopService (which is + // what the static AreYouThereOverlay.dismiss() does). + val ok = device.wait( + Until.findObject(By.res(targetContext.packageName, "buttonImOk")), + 5_000L + ) + assertNotNull(ok) + + AreYouThereOverlay.dismiss(targetContext) + + val gone = device.wait( + Until.gone(By.res(targetContext.packageName, "buttonImOk")), + 5_000L + ) + assertTrue("dismiss() must remove the overlay", gone) + } +} diff --git a/app/src/androidTest/java/io/keepalive/android/SettingsPersistenceInstrumentedTest.kt b/app/src/androidTest/java/io/keepalive/android/SettingsPersistenceInstrumentedTest.kt new file mode 100644 index 0000000..404aae5 --- /dev/null +++ b/app/src/androidTest/java/io/keepalive/android/SettingsPersistenceInstrumentedTest.kt @@ -0,0 +1,94 @@ +package io.keepalive.android + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import io.keepalive.android.AlertFlowTestUtil.resetToCleanEnabledState +import io.keepalive.android.AlertFlowTestUtil.targetContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.BeforeClass +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Instrumented tests that settings written via credential-encrypted prefs + * correctly propagate to device-protected storage, so Direct Boot sees them. + * + * This is pure pref-level plumbing; the machinery runs on real Android. + */ +@RunWith(AndroidJUnit4::class) +// Device-protected storage, the credential→device mirror, and the +// last_alarm_stage write are all gated on API N (24)+ in the production +// code. Pre-N these assertions read null and fail. API 22 skipped. +@SdkSuppress(minSdkVersion = android.os.Build.VERSION_CODES.N) +class SettingsPersistenceInstrumentedTest { + + companion object { + @JvmStatic + @BeforeClass + fun grantAllPermissions() { + TestSetupUtil.setupTestEnvironment() + } + } + + @Before fun setUp() { + resetToCleanEnabledState() + } + + @After fun tearDown() { + AlertFlowTestUtil.cancelAnyPendingAlarms() + } + + @Test fun syncCopiesCredentialEncryptedSettingsToDeviceProtected() { + // Write a handful of values via the default prefs (the settings UI + // would normally do this via preference-fragment). + androidx.preference.PreferenceManager.getDefaultSharedPreferences(targetContext) + .edit() + .putString("time_period_hours", "8") + .putString("followup_time_period_minutes", "45") + .putBoolean("enabled", true) + .commit() + + syncPrefsToDeviceProtectedStorage(targetContext) + + val dev = getDeviceProtectedPreferences(targetContext) + assertEquals("8", dev.getString("time_period_hours", null)) + assertEquals("45", dev.getString("followup_time_period_minutes", null)) + assertEquals(true, dev.getBoolean("enabled", false)) + } + + @Test fun settingAnAlarmStoresTimestampInBothStores() { + // setAlarm writes NextAlarmTimestamp to credential-encrypted AND + // mirrors to device-protected — critical for Direct Boot recovery. + setAlarm( + targetContext, + lastActivityTimestamp = System.currentTimeMillis(), + desiredAlarmInMinutes = 30, + alarmStage = "periodic", + restPeriods = null + ) + + val cred = getEncryptedSharedPreferences(targetContext) + .getLong("NextAlarmTimestamp", 0L) + val dev = getDeviceProtectedPreferences(targetContext) + .getLong("NextAlarmTimestamp", 0L) + + assertTrue("credential-encrypted store has the timestamp", cred > 0L) + assertEquals("device-protected store must mirror it", cred, dev) + } + + @Test fun alarmStageIsPersistedToDeviceProtectedStorage() { + setAlarm( + targetContext, + lastActivityTimestamp = System.currentTimeMillis(), + desiredAlarmInMinutes = 10, + alarmStage = "final", + restPeriods = null + ) + + assertEquals("final", + getDeviceProtectedPreferences(targetContext).getString("last_alarm_stage", null)) + } +} diff --git a/app/src/fDroid/java/io/keepalive/android/LocationHelper.kt b/app/src/fDroid/java/io/keepalive/android/LocationHelper.kt index 3b0d9e4..6f6c5ea 100644 --- a/app/src/fDroid/java/io/keepalive/android/LocationHelper.kt +++ b/app/src/fDroid/java/io/keepalive/android/LocationHelper.kt @@ -40,6 +40,13 @@ class LocationHelper( lastLocation.provider, lastLocation.accuracy) ) + // populate locationResult so downstream consumers (webhook + // body, SMS payload) see the real coords, not the 0,0 default. + // Mirrors what processCurrentLocationResults does above. + locationResult.latitude = lastLocation.latitude + locationResult.longitude = lastLocation.longitude + locationResult.accuracy = lastLocation.accuracy + // attempt to geocode the location and then execute the callback if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { GeocodingHelperAPI33Plus().geocodeLocationAndExecute(lastLocation) diff --git a/app/src/main/java/io/keepalive/android/AlertCheckDeps.kt b/app/src/main/java/io/keepalive/android/AlertCheckDeps.kt index 287ed2c..33d871b 100644 --- a/app/src/main/java/io/keepalive/android/AlertCheckDeps.kt +++ b/app/src/main/java/io/keepalive/android/AlertCheckDeps.kt @@ -139,10 +139,27 @@ class ProductionAlertCheckDeps(private val context: Context) : AlertCheckDeps { intent.putExtra(AlertService.EXTRA_ALERT_TRIGGER_TIMESTAMP, System.currentTimeMillis()) DebugLogger.d("doAlertCheck", context.getString(R.string.debug_log_alert_service_start)) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) + // API 31+ can throw ForegroundServiceStartNotAllowedException + // (or BackgroundServiceStartNotAllowedException, depending on + // version) when start{Foreground,}Service is called from a + // background context that doesn't currently hold a + // foreground-service exemption. A real AlarmManager broadcast + // gets the exemption automatically; an exotic code path or test + // harness that synthesizes the broadcast does not. We must NOT + // let that bubble up — the SMS / call / webhook flow is the + // primary alert mechanism and should at minimum log if the + // service can't be started rather than swallowed silently + // upstream by the AlarmReceiver's catch-all. + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (t: Throwable) { + DebugLogger.d("doAlertCheck", + "Failed to start AlertService: ${t.localizedMessage}", + t as? Exception) } } 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..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. @@ -130,31 +124,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) @@ -316,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") + val steps = object : AlertStepOps { + override fun isComplete(step: Int): Boolean = isStepComplete(step) + override fun markComplete(step: Int) = markStepComplete(step) } - // update prefs to include when the alert was sent; not actually used for anything - prefs.edit(commit = true) { - putLong("LastAlertAt", System.currentTimeMillis()) - } - - 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 +} diff --git a/app/src/main/java/io/keepalive/android/AreYouThereOverlayService.kt b/app/src/main/java/io/keepalive/android/AreYouThereOverlayService.kt index 32c968d..b62379d 100644 --- a/app/src/main/java/io/keepalive/android/AreYouThereOverlayService.kt +++ b/app/src/main/java/io/keepalive/android/AreYouThereOverlayService.kt @@ -41,7 +41,28 @@ class AreYouThereOverlayService : Service() { override fun onBind(intent: Intent?): IBinder? = null + override fun onCreate() { + super.onCreate() + // Android (O+) contract: if the service was started with + // startForegroundService(), it MUST call Service.startForeground() + // within ~5s or the system kills the app with + // ForegroundServiceDidNotStartInTimeException. Calling + // startForeground in onCreate is the earliest possible point — + // onStartCommand may be delayed or skipped in edge cases (process + // restart on START_NOT_STICKY, OS queueing under load). + try { + ensureForeground() + } catch (t: Throwable) { + Log.e(TAG, "ensureForeground failed in onCreate", t) + } + } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + // Redundant safety — usually a no-op since onCreate already did it. + try { ensureForeground() } catch (t: Throwable) { + Log.e(TAG, "ensureForeground failed in onStartCommand", t) + } + try { when (intent?.action) { ACTION_SHOW -> { @@ -76,13 +97,16 @@ class AreYouThereOverlayService : Service() { if (!canDrawOverlays(this)) { Log.d(TAG, "Overlay permission not granted; not showing overlay") + // stopForeground+stopSelf — onStartCommand already called + // startForeground, we must tear that down cleanly. + stopForegroundIfNeeded() stopSelf() return } - // Keep the process/service alive while the overlay is visible. - // This improves reliability when triggered from the background on Android O+. - ensureForeground() + // ensureForeground() is already invoked in onStartCommand so we're + // guaranteed the foreground-service contract was honored before any + // early return. No-op here since isForeground is already true. val wm = getSystemService(WINDOW_SERVICE) as WindowManager val view = LayoutInflater.from(this).inflate(R.layout.overlay_are_you_there, null, false) @@ -306,10 +330,21 @@ class AreYouThereOverlayService : Service() { putExtra(EXTRA_MESSAGE, message) } // When triggered from background on Android O+, use startForegroundService. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(i) - } else { - context.startService(i) + // Wrap in try/catch: on API 31+ Android can throw + // ForegroundServiceStartNotAllowedException if we're not in an + // exempt state (e.g. background after a non-system-broadcast + // alarm wake-up). The overlay is supplementary — the + // "Are you there?" notification is the primary signal, and we + // must NEVER let an overlay-start failure crash the alert flow. + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(i) + } else { + context.startService(i) + } + } catch (t: Throwable) { + Log.w("AreYouThereOverlay", + "Failed to start overlay service; the notification is still posted", t) } } 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/UtilityFunctions.kt b/app/src/main/java/io/keepalive/android/UtilityFunctions.kt index ca3cf59..d311bb7 100644 --- a/app/src/main/java/io/keepalive/android/UtilityFunctions.kt +++ b/app/src/main/java/io/keepalive/android/UtilityFunctions.kt @@ -517,22 +517,36 @@ fun calculateOffsetDateTimeExcludingRestPeriod( Log.d("calcPastDtExcRestPeriod", "Invalid rest period? $restPeriod") thisTargetDtCalendar.add(Calendar.MINUTE, minuteStep * minutesToOffset) } else { + // Each iteration traverses one wall-clock minute. We classify that minute + // as rest/active by checking isWithinRestPeriod at the START of the + // wall-clock minute traversed (the lower bound of [t, t+1)). + // + // For forward (step=+1) the start is the cal value BEFORE advancing. + // For backward (step=-1) the start is the cal value AFTER advancing + // (advance-then-check makes the new cal equal to the lower bound). while (minutesToOffset > 0) { - // every minute, check whether the current time is within the rest period and, if not, - // offset another minute until we get to 0 minutes, i.e. the end of the check period - - thisTargetDtCalendar.add(Calendar.MINUTE, minuteStep) - - // if the current time isn't within the rest period, offset another minute - if (!isWithinRestPeriod( + if (minuteStep > 0) { + // Forward: classify by START of the upcoming minute, then advance. + val inRest = isWithinRestPeriod( thisTargetDtCalendar.get(Calendar.HOUR_OF_DAY), thisTargetDtCalendar.get(Calendar.MINUTE), restPeriod - )) { - minutesToOffset-- + ) + thisTargetDtCalendar.add(Calendar.MINUTE, minuteStep) + if (inRest) skippedMinutes++ else minutesToOffset-- } else { - skippedMinutes++ + // Backward: advance first; new cal is the start of the traversed minute. + thisTargetDtCalendar.add(Calendar.MINUTE, minuteStep) + if (!isWithinRestPeriod( + thisTargetDtCalendar.get(Calendar.HOUR_OF_DAY), + thisTargetDtCalendar.get(Calendar.MINUTE), + restPeriod + )) { + minutesToOffset-- + } else { + skippedMinutes++ + } } } } 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 +} 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..f38d5ca --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AcknowledgeAreYouThereTest.kt @@ -0,0 +1,133 @@ +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 +import org.robolectric.annotation.Config + +/** + * 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) +// acknowledge() has an `>= N (24)` branch for device-protected prefs; +// matrix exercises pre-N and post-N behavior. +@Config(sdk = [23, 28, 33, 34, 35, 36]) +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 + @Config(sdk = [28, 33, 34, 35, 36]) // device-protected storage is API N+ + fun `clears the direct boot notification pending flag on API N+`() { + AcknowledgeAreYouThere.acknowledge(appCtx) + + assertFalse(getDeviceProtectedPreferences(appCtx) + .getBoolean("direct_boot_notification_pending", true)) + } + + @Test + @Config(sdk = [28, 33, 34, 35, 36]) + 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 + @Config(sdk = [28, 33, 34, 35, 36]) + 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..2030adb --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertMessageSenderTest.kt @@ -0,0 +1,256 @@ +package io.keepalive.android + +import android.content.Context +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 +import org.robolectric.annotation.Config + +/** + * 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) +// sendAlertMessage branches at API T (33) on registerReceiver flags. +// Pre-33 uses ContextCompat with a synthesized RECEIVER_NOT_EXPORTED +// permission — real devices accept it (manifest merger adds the perm) but +// Robolectric's sandboxed manifest doesn't, causing registerReceiver to +// throw. Production behavior there is covered via instrumented tests on +// emulators at API 22/28. Unit tests stay at T+ where the runtime APIs +// are direct. +@Config(sdk = [33, 34, 35, 36]) +class AlertMessageSenderTest { + + /** + * Reads the list of currently-registered receivers from Robolectric's + * `ShadowApplication`. More reliable than a ContextWrapper override — + * `ContextCompat.registerReceiver` takes different code paths per SDK + * that can bypass overridden `registerReceiver` signatures, but all + * paths ultimately register with the application which the shadow + * tracks uniformly. + */ + private fun registeredSMSSentReceiverCount(): Int { + val shadowApp = org.robolectric.Shadows.shadowOf(appCtx as android.app.Application) + return shadowApp.registeredReceivers.count { wrapper -> + wrapper.broadcastReceiver is io.keepalive.android.receivers.SMSSentReceiver + } + } + + 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 sms = mockSms(emptyMap()) + + AlertMessageSender(appCtx, sms).sendAlertMessage() + + assertEquals("no receiver should register when nothing will be sent", + 0, registeredSMSSentReceiverCount()) + 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 sms = mockSms(mapOf("real" to arrayListOf("real"))) + + AlertMessageSender(appCtx, sms).sendAlertMessage() + + assertEquals(1, registeredSMSSentReceiverCount()) + 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 sms = mockSms(mapOf("help" to arrayListOf("help"))) + + AlertMessageSender(appCtx, sms).sendAlertMessage() + + assertEquals("exactly one SMSSentReceiver should be registered per batch", + 1, registeredSMSSentReceiverCount()) + 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 sms = mockSms(mapOf("long message" to arrayListOf("long ", "message"))) + + val partsSlot = slot>() + val pisSlot = slot>() + + AlertMessageSender(appCtx, 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 sms = mockSms(mapOf("help" to arrayListOf("help"))) + + AlertMessageSender(appCtx, sms).sendAlertMessage() + + assertEquals("exactly ONE receiver per batch — not one per contact", + 1, registeredSMSSentReceiverCount()) + verify(exactly = 3) { sms.sendTextMessage(any(), any(), any(), any(), any()) } + } + + @Test fun `null smsManager short-circuits with no sends and no receiver`() { + AlertMessageSender(appCtx, smsManager = null).sendAlertMessage() + assertEquals(0, registeredSMSSentReceiverCount()) + } + + @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 sms = mockSms(mapOf( + "short" to arrayListOf("short"), + "long one" to arrayListOf("long ", "one") + )) + + AlertMessageSender(appCtx, sms).sendAlertMessage() + + assertEquals(1, registeredSMSSentReceiverCount()) + 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 sms = mockSms(mapOf("help" to arrayListOf("help"))) + + AlertMessageSender(appCtx, 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()) + } + } + + // ---- SEND_SMS permission denied ---------------------------------------- + // + // Production code does not pre-check Manifest.permission.SEND_SMS — it + // relies on `SmsManager.sendTextMessage(...)` throwing SecurityException, + // which the per-contact try/catch swallows after posting a failure + // notification. These tests pin that contract: a denied SEND_SMS at the + // OS level surfaces as an exception from the SmsManager call, the alert + // path keeps running (other contacts still attempt), and the user gets + // a status notification telling them the alert didn't go. + + @Test fun `SecurityException from sendTextMessage posts the failure notification`() { + val sms = mockSms(mapOf("help" to arrayListOf("help"))) + io.mockk.every { + sms.sendTextMessage(any(), any(), any(), any(), any()) + } throws SecurityException("SEND_SMS denied") + + // POST_NOTIFICATIONS needed on T+ for the failure notification to surface. + org.robolectric.Shadows.shadowOf(appCtx as android.app.Application) + .grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS) + val nm = appCtx.getSystemService(Context.NOTIFICATION_SERVICE) + as android.app.NotificationManager + nm.cancelAll() + + AlertMessageSender(appCtx, sms).sendAlertMessage() + + val active = nm.activeNotifications + assertEquals("a failure notification must be posted when SMS send throws", + 1, active.size) + assertEquals(AppController.SMS_ALERT_SENT_NOTIFICATION_ID, active[0].id) + } + + @Test fun `SecurityException from one contact does not stop the loop`() { + // First contact's send throws (e.g. a particular number triggers + // carrier-blocked exception); second contact must still attempt. + seedContacts( + contact("+15550000001"), + contact("+15550000002") + ) + val sms = mockSms(mapOf("help" to arrayListOf("help"))) + io.mockk.every { + sms.sendTextMessage("+15550000001", any(), any(), any(), any()) + } throws SecurityException("blocked") + + AlertMessageSender(appCtx, sms).sendAlertMessage() + + verify(exactly = 1) { sms.sendTextMessage("+15550000001", null, "help", any(), null) } + verify(exactly = 1) { sms.sendTextMessage("+15550000002", null, "help", any(), null) } + } +} 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..102e48a --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertNotificationHelperTest.kt @@ -0,0 +1,188 @@ +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 +import org.robolectric.annotation.Config + +/** + * Tests [AlertNotificationHelper]: channel creation, dedup, cancel, and + * the "Are you there?" branch that makes the notification ongoing + non-auto-cancel. + * + * Matrix covers notification channels (API O/26+), POST_NOTIFICATIONS runtime + * permission check (API T/33+), and the pre-O branch that uses priority/defaults + * directly instead of a channel. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [23, 28, 33, 34, 35, 36]) +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 + @Config(sdk = [28, 33, 34, 35, 36]) // notification channels are API O (26)+ + fun `constructor creates all expected channels on API 26+`() { + 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 + @Config(sdk = [23]) // pre-O: channels don't exist yet + fun `constructor does not crash on API 23 (pre-O, no notification-channel subsystem)`() { + // The whole notification-channel subsystem is API O+. On 23 the + // constructor must silently skip channel creation. Verified by + // constructing the helper without throwing — getNotificationChannels() + // isn't available to check directly pre-O. + AlertNotificationHelper(appCtx) // must not throw + } + + @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) + } + + // ---- POST_NOTIFICATIONS permission gate (API 33+) --------------------- + + @Test + @Config(sdk = [33, 34, 35, 36]) // POST_NOTIFICATIONS is API T (33)+ + fun `sendNotification is a no-op on API 33+ when POST_NOTIFICATIONS is denied`() { + // The default Robolectric state denies runtime permissions; the @Before + // grants POST_NOTIFICATIONS for every other test so the helper actually + // surfaces. Here we revoke it (deny + clear before grant) and assert + // the early-return at NotificationHelper.kt around line 150 fires. + shadowOf(appCtx as Application).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + + AlertNotificationHelper(appCtx).sendNotification( + "ignored", + "denied state", + AppController.SMS_ALERT_SENT_NOTIFICATION_ID + ) + + assertEquals("nothing should post when POST_NOTIFICATIONS is denied", + 0, shadowOf(nm).allNotifications.size) + } + + @Test + @Config(sdk = [23, 28]) // pre-T: permission doesn't exist, gate is bypassed + fun `sendNotification works pre-T even with POST_NOTIFICATIONS denied (perm did not exist)`() { + // POST_NOTIFICATIONS only exists on T+. Pre-T, the gate at line 150 + // is skipped entirely (the SDK_INT check), so denying the permission + // has no effect and the notification still posts. + shadowOf(appCtx as Application).denyPermissions(Manifest.permission.POST_NOTIFICATIONS) + + AlertNotificationHelper(appCtx).sendNotification( + "shown", + "pre-T path", + AppController.SMS_ALERT_SENT_NOTIFICATION_ID + ) + + assertEquals("pre-T must ignore POST_NOTIFICATIONS state and post", + 1, shadowOf(nm).allNotifications.size) + } +} diff --git a/app/src/test/java/io/keepalive/android/AlertServiceWakeLockTest.kt b/app/src/test/java/io/keepalive/android/AlertServiceWakeLockTest.kt new file mode 100644 index 0000000..b777794 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertServiceWakeLockTest.kt @@ -0,0 +1,140 @@ +package io.keepalive.android + +import android.content.Context +import android.content.Intent +import android.os.PowerManager +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.Robolectric +import org.robolectric.RobolectricTestRunner + +/** + * Wake-lock lifecycle tests for [AlertService]. + * + * Robolectric 4.16 dropped `ShadowPowerManager.getLatestWakeLock()`. We + * introspect the service's private `wakeLock` field directly — more robust + * across Robolectric versions and checks what the production code actually + * holds, not a shadow's recollection of it. + */ +@RunWith(RobolectricTestRunner::class) +class AlertServiceWakeLockTest { + + private val appCtx: Context = ApplicationProvider.getApplicationContext() + + private fun wakeLockOf(service: AlertService): PowerManager.WakeLock { + val field = AlertService::class.java.getDeclaredField("wakeLock") + field.isAccessible = true + return field.get(service) as PowerManager.WakeLock + } + + private fun newAlertIntent(triggerTimestamp: Long = System.currentTimeMillis()) = + Intent(appCtx, AlertService::class.java).apply { + putExtra(AlertService.EXTRA_ALERT_TRIGGER_TIMESTAMP, triggerTimestamp) + } + + @Before fun clearPrefs() { + getEncryptedSharedPreferences(appCtx).edit().clear().commit() + } + + @Test fun `onCreate constructs a PARTIAL wake lock`() { + val service = Robolectric.buildService(AlertService::class.java).create().get() + + val wakeLock = wakeLockOf(service) + assertNotNull(wakeLock) + // We can't directly inspect the level (PARTIAL vs FULL) without + // reflecting on PowerManager internals. Instead confirm it's the + // lock the service will use — it's not held yet because + // onStartCommand hasn't run. + assertFalse("onCreate must not acquire — only construct", wakeLock.isHeld) + + service.onDestroy() + } + + @Test fun `onStartCommand acquires the wake lock`() { + val controller = Robolectric.buildService(AlertService::class.java, newAlertIntent()) + .create() + .startCommand(0, 1) + val wakeLock = wakeLockOf(controller.get()) + + assertTrue("onStartCommand must acquire", wakeLock.isHeld) + + controller.get().onDestroy() + } + + @Test fun `onDestroy releases the wake lock`() { + val controller = Robolectric.buildService(AlertService::class.java, newAlertIntent()) + .create() + .startCommand(0, 1) + val wakeLock = wakeLockOf(controller.get()) + assertTrue(wakeLock.isHeld) + + controller.get().onDestroy() + + assertFalse("onDestroy must release", wakeLock.isHeld) + } + + @Test fun `back-to-back acquires do not leak due to refcounting`() { + // Historical bug: default WakeLock reference-counting meant N acquires + // required N releases. AlertService.onCreate disables refcounting so a + // single release() in onDestroy fully unwinds. + val controller = Robolectric.buildService(AlertService::class.java, newAlertIntent()) + .create() + .startCommand(0, 1) + .startCommand(0, 2) + .startCommand(0, 3) + val wakeLock = wakeLockOf(controller.get()) + assertTrue(wakeLock.isHeld) + + controller.get().onDestroy() + + assertFalse("single release() must fully release regardless of acquire count", + wakeLock.isHeld) + } + + @Test fun `stale-trigger intent does not hold a wake lock past the dedup guard`() { + // Seed a newer trigger as "already saved", then deliver an older one. + val prefs = getEncryptedSharedPreferences(appCtx) + prefs.edit() + .putLong("AlertTriggerTimestamp", 10_000L) + .putInt("AlertStepsCompleted", 0) + .commit() + + val staleIntent = Intent(appCtx, AlertService::class.java).apply { + putExtra(AlertService.EXTRA_ALERT_TRIGGER_TIMESTAMP, 5_000L) + } + val controller = Robolectric.buildService(AlertService::class.java, staleIntent) + .create() + .startCommand(0, 1) + val wakeLock = wakeLockOf(controller.get()) + + // Dedup guard returned START_REDELIVER_INTENT before wakeLock.acquire(). + assertFalse("stale intent must not hold a wake lock", wakeLock.isHeld) + + controller.get().onDestroy() + } + + @Test fun `wakeLock reference-counting is disabled so isHeld flips on a single release`() { + // Bare behavioral check: even if something other than AlertService + // acquires this exact WakeLock twice, a single release should fully + // release it (because setReferenceCounted(false) was called in onCreate). + val service = Robolectric.buildService(AlertService::class.java).create().get() + val wakeLock = wakeLockOf(service) + + wakeLock.acquire(60_000L) + wakeLock.acquire(60_000L) + wakeLock.acquire(60_000L) + assertTrue(wakeLock.isHeld) + + wakeLock.release() + + assertFalse("refcount was disabled — single release fully unwinds", wakeLock.isHeld) + + service.onDestroy() + } +} 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..57194af --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AlertStepRunnerTest.kt @@ -0,0 +1,352 @@ +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) + } + + // ======================================================================== + // Per-step exception → retry semantics (no bit flips on throw) + // ======================================================================== + + @Test fun `sendSmsAlert exception leaves STEP_SMS_SENT unset for next-delivery retry`() { + // Pin the contract: if SMS dispatch throws (e.g., a SecurityException + // from a denied SEND_SMS), the step bit stays UNSET so a subsequent + // intent re-delivery (Android's START_REDELIVER_INTENT) will retry + // it. If a future refactor moved markComplete into a `finally` block + // or before the dispatch, the SMS would be silently skipped on + // retry — this test catches that regression. + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher().apply { + smsSendThrows = SecurityException("SEND_SMS denied") + } + + try { + runAlertSteps(steps, disp) + org.junit.Assert.fail("sendSmsAlert exception should propagate to caller") + } catch (_: SecurityException) { /* expected */ } + + assertFalse( + "SMS bit must NOT be set when sendSmsAlert threw — drives retry on redelivery", + steps.isComplete(STEP_SMS_SENT) + ) + // Call step never reached because runAlertSteps short-circuited. + assertEquals(0, disp.callCount) + } + + @Test fun `makeCall exception leaves STEP_CALL_MADE unset but SMS step stays marked`() { + // After SMS succeeds and is marked complete, a subsequent call-step + // failure must NOT roll back the SMS bit — that would cause a + // re-delivery to re-send all SMSes, double-paging the contacts. + // Per-step bits, not all-or-nothing. + val steps = FakeAlertStepOps() + val disp = FakeAlertDispatcher().apply { + callThrows = RuntimeException("dialer crashed") + } + + try { + runAlertSteps(steps, disp) + org.junit.Assert.fail("makeCall exception should propagate") + } catch (_: RuntimeException) { /* expected */ } + + assertTrue( + "SMS bit must remain set after a successful SMS dispatch, even " + + "if a later step throws", + steps.isComplete(STEP_SMS_SENT) + ) + assertFalse( + "CALL bit must NOT be set when makeCall threw — drives retry", + steps.isComplete(STEP_CALL_MADE) + ) + } + + @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/AppControllerTest.kt b/app/src/test/java/io/keepalive/android/AppControllerTest.kt new file mode 100644 index 0000000..7c7ff9e --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AppControllerTest.kt @@ -0,0 +1,118 @@ +package io.keepalive.android + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Smoke tests for [AppController]. The class is a thin Application subclass + * but it has two failure modes worth pinning: + * + * 1. **Constants drift.** Notification channel IDs, notification IDs, and + * request codes are all referenced across the codebase by name. A + * refactor that renumbers a notification ID would silently merge two + * notifications (e.g. SMS-success + SMS-failure both posting under id=2) + * because the system de-duplicates by id. Pinning the values here makes + * such a renumber a deliberate, visible change. + * + * 2. **onCreate startup.** `AppController.onCreate` runs at every app + * start, including Direct Boot. It calls `DebugLogger.initialize(this)` + * and reads `applicationInfo.flags` — if either of those starts + * throwing under a future SDK or a refactored DebugLogger, every + * instrumented + every Robolectric test silently breaks. This smoke + * test exercises the path with a real Application context. + */ +@RunWith(RobolectricTestRunner::class) +class AppControllerTest { + + private val app: Application = ApplicationProvider.getApplicationContext() + + @Test fun `AppController is the Application instance under Robolectric`() { + // Robolectric reads the `android:name` from the test manifest. If a + // future build accidentally drops the AppController declaration, + // the type-check below fails. Without AppController.onCreate + // running, DebugLogger never initializes — every test that depends + // on file-backed debug logs would hit lazy init unpredictably. + assertTrue("Application must be the AppController subclass — " + + "manifest android:name must point at io.keepalive.android.AppController", + app is AppController) + } + + @Test fun `notification channel and ID constants are stable`() { + // These values are the identity by which Android dedupes/groups + // notifications and channels. Pin them so a future refactor that + // accidentally collapses or renumbers them surfaces here. + assertEquals("AlertNotificationChannel", + AppController.ARE_YOU_THERE_NOTIFICATION_CHANNEL_ID) + assertEquals("CallSentNotificationChannel", + AppController.CALL_SENT_NOTIFICATION_CHANNEL_ID) + assertEquals("SMSSentNotificationChannel", + AppController.SMS_SENT_NOTIFICATION_CHANNEL_ID) + assertEquals("AlertServiceNotificationChannel", + AppController.ALERT_SERVICE_NOTIFICATION_CHANNEL_ID) + assertEquals("WebhookSentNotificationChannel", + AppController.WEBHOOK_SENT_NOTIFICATION_CHANNEL_ID) + + // Numeric IDs — pinned because the SMSSentReceiver uses two + // DIFFERENT IDs (success vs failure) so a partial multipart send + // shows both notifications. Collapsing them would lose that signal. + assertEquals(1, AppController.ARE_YOU_THERE_NOTIFICATION_ID) + assertEquals(2, AppController.SMS_ALERT_SENT_NOTIFICATION_ID) + assertEquals(3, AppController.CALL_ALERT_SENT_NOTIFICATION_ID) + assertEquals(4, AppController.SMS_ALERT_FAILURE_NOTIFICATION_ID) + assertEquals(5, AppController.ALERT_SERVICE_NOTIFICATION_ID) + assertEquals(6, AppController.WEBHOOK_ALERT_SENT_NOTIFICATION_ID) + } + + @Test fun `notification IDs are unique`() { + // The bug we're guarding against: someone accidentally setting two + // constants to the same value. Notifications collapsed under one + // id silently overwrite each other. + val ids = listOf( + AppController.ARE_YOU_THERE_NOTIFICATION_ID, + AppController.SMS_ALERT_SENT_NOTIFICATION_ID, + AppController.CALL_ALERT_SENT_NOTIFICATION_ID, + AppController.SMS_ALERT_FAILURE_NOTIFICATION_ID, + AppController.ALERT_SERVICE_NOTIFICATION_ID, + AppController.WEBHOOK_ALERT_SENT_NOTIFICATION_ID + ) + assertEquals("notification IDs must be unique; collisions silently " + + "deduplicate notifications. ids=$ids", + ids.size, ids.toSet().size) + } + + @Test fun `request and result codes do not collide`() { + // Same hazard, different failure mode: AlarmReceiver pending intents + // share the request code namespace with hibernation activity + // results. A collision would route alarm intents into the + // hibernation handler. + org.junit.Assert.assertNotEquals( + AppController.ACTIVITY_ALARM_REQUEST_CODE, + AppController.APP_HIBERNATION_ACTIVITY_RESULT_CODE + ) + } + + @Test fun `MIN_API_LEVEL_FOR_DEVICE_LOCK_UNLOCK is API P`() { + // The keyguard-event activity-detection path in DeviceActivityQuery + // is gated on this constant. Bumping it without updating the + // gate logic would silently change which APIs use the fallback + // MOVE_TO_FOREGROUND path — that's the kind of change the + // commit author should explicitly rationalize. + assertEquals(android.os.Build.VERSION_CODES.P, + AppController.MIN_API_LEVEL_FOR_DEVICE_LOCK_UNLOCK) + } + + @Test fun `onCreate completed without throwing during Robolectric setup`() { + // Robolectric calls Application.onCreate() during test setup. If + // DebugLogger.initialize() were to start throwing (file-system + // restriction, missing dir, etc.), the test process would have + // crashed before this method runs. Reaching this assertion proves + // onCreate completed at least once cleanly. + assertNotNull(app) + } +} 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..7957937 --- /dev/null +++ b/app/src/test/java/io/keepalive/android/AreYouThereOverlayServiceTest.kt @@ -0,0 +1,185 @@ +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