Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0f3cc72
refactor to make testing easier
keepalivedev Apr 19, 2026
53fd2de
add testing dependencies
keepalivedev Apr 19, 2026
58a8f6d
add tests
keepalivedev Apr 19, 2026
0df78b3
refactor sendAlert to make it easier to test
keepalivedev Apr 19, 2026
d8ddc1d
add more tests
keepalivedev Apr 19, 2026
c7413d0
add more tests
keepalivedev Apr 19, 2026
a261f40
fix edge case with getLastLocation
keepalivedev Apr 19, 2026
8232a94
fix edge case with AreYouThereOverlayService
keepalivedev Apr 19, 2026
d0b4413
add test dependency
keepalivedev Apr 19, 2026
aba5f90
add instrumented tests
keepalivedev Apr 19, 2026
c506347
add/update tests
keepalivedev Apr 25, 2026
29f5222
add api 36 to tests
keepalivedev Apr 26, 2026
62bacac
change java version to 21
keepalivedev Apr 26, 2026
def9c7f
add github actions to run tests
keepalivedev Apr 26, 2026
9d4dcd3
update github action
keepalivedev Apr 26, 2026
499871a
update github action
keepalivedev Apr 26, 2026
1a86548
add try/catch to overlay service start
keepalivedev Apr 26, 2026
eb255b7
exclude most tests for API 22 and add new class to test what can be t…
keepalivedev Apr 26, 2026
2a70ae9
update tests
keepalivedev Apr 26, 2026
0ccb05f
add new test dependency
keepalivedev Apr 26, 2026
23802b3
update tests
keepalivedev Apr 27, 2026
feaf927
add try/catch to alert service start
keepalivedev May 3, 2026
6d3f7a7
add test properties
keepalivedev May 3, 2026
c95f100
add more tests
keepalivedev May 9, 2026
6c51ca7
update tests
keepalivedev May 9, 2026
30b59a4
fix edge case in rest period logic
keepalivedev May 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/generate-apk-aab-debug-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions .github/workflows/instrumented-tests.yml
Original file line number Diff line number Diff line change
@@ -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/
40 changes: 40 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
@@ -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/
41 changes: 32 additions & 9 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -131,12 +138,28 @@ 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'
androidTestImplementation 'androidx.test.espresso:espresso-contrib:3.7.0'
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'
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading