Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
122 changes: 80 additions & 42 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,70 +3,108 @@ name: Android Build
on: pull_request

jobs:
build:
build-and-test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Setup JDK
- name: Setup JDK 21
uses: actions/setup-java@v4
with:
java-version: '17'
java-version: '21'
distribution: 'zulu'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Make gradlew executable
run: chmod +x ./gradlew

- name: Build Project
run: ./gradlew assemble
# --- Build ---

- name: Build Default Debug APK
run: ./gradlew assembleDefaultDebug

- name: Build GooglePlay Debug APK
run: ./gradlew assembleGooglePlayDebug

# --- Unit Tests ---

- name: Run Tests
run: ./gradlew test
- name: Run Default flavor unit tests
run: ./gradlew testDefaultDebugUnitTest

- name: Get apk path
id: apk-path-id
run: echo "::set-output name=apk-path::$(find app -name "*.apk" | head -1)"
shell: bash
- name: Run GooglePlay flavor unit tests
run: ./gradlew testGooglePlayDebugUnitTest
Comment thread
spuday90 marked this conversation as resolved.

- name: Upload Default Apk
# --- Coverage Report ---

- name: Generate JaCoCo coverage report
run: ./gradlew jacocoUnitTestReport

- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
id: upload
with:
name: PR-${{ github.event.number }}
path: ${{ steps.apk-path-id.outputs.apk-path }}
retention-days: 2
name: coverage-report
path: app/build/reports/jacoco/jacocoUnitTestReport/html/
retention-days: 7

- name: Build GooglePlay Variant
run: ./gradlew assembleGooglePlay
# --- Upload APKs ---

- name: Get apk path
id: google-play-apk-path-id
run: echo "::set-output name=google-play-apk-path::$(find app -name "*GooglePlay*.apk" | head -1)"
shell: bash
- name: Upload Default APK
uses: actions/upload-artifact@v4
with:
name: PR-${{ github.event.number }}-default
path: app/build/outputs/apk/Default/debug/*.apk
retention-days: 2

- name: Upload GooglePlay Apk
- name: Upload GooglePlay APK
uses: actions/upload-artifact@v4
id: upload-googpe-play
with:
name: PR-${{ github.event.number }}-googleplay
path: ${{ steps.google-play-apk-path-id.outputs.google-play-apk-path }}
path: app/build/outputs/apk/GooglePlay/debug/*.apk
retention-days: 2

# Diabled as uploading to transfer.sh is not working. Need to find another provider or self host one to get this working again
#- name: upload apk
# uses: wei/curl@v1
# with:
# args: --upload-file ${{ steps.apk-path-id.outputs.apk-path }} https://transfer.sh/PR-${{ github.event.number }}.apk -o apkpath.txt

#- name: upload apk
# id: upload-apk-path-id
# run: |
# echo "::set-output name=apk-path::$(cat apkpath.txt)"
# shell: bash

#- name: comment on PR with download link
# uses: mshick/add-pr-comment@v2
# with:
# message: |
# **Download apk from path: ${{ steps.upload-apk-path-id.outputs.apk-path }}**
instrumentation-tests:
runs-on: ubuntu-latest
# Instrumentation tests require an emulator and take longer.
# Run in parallel with the build job to save time.

steps:
- uses: actions/checkout@v4

- name: Setup JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'zulu'

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Make gradlew executable
run: chmod +x ./gradlew

- name: Enable KVM for Android emulator
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 instrumentation tests on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 28
arch: x86_64
emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
disable-animations: true
script: ./gradlew connectedDefaultDebugAndroidTest

- name: Upload instrumentation test results
if: always()
uses: actions/upload-artifact@v4
with:
name: instrumentation-test-results
path: app/build/reports/androidTests/connected/
retention-days: 7
173 changes: 173 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# Watomatic – Developer Guide

Watomatic is an Android app that auto-replies to WhatsApp (and other messenger) notifications. It uses a notification listener service to intercept messages and send replies via `RemoteInput` actions.

## Requirements

- **Android Studio** (Meerkat or later recommended)
- **JDK 21** – bundled with Android Studio at `/Applications/Android Studio.app/Contents/jbr/Contents/Home`
- **Android SDK** – set in `local.properties` (`sdk.dir=/Users/<you>/Library/Android/sdk`)
- No `google-services.json` needed for the `Default` flavor (open-source build)

## Project structure

```
app/src/main/java/…/
model/ – Business logic (CustomRepliesData, PreferencesManager, …)
model/utils/ – Utility classes (NotificationUtils, AppUtils, …)
network/ – Retrofit interfaces and request/response models
service/ – NotificationService (core auto-reply logic)
activity/ – UI activities
fragment/ – UI fragments
```

**Product flavors:**
- `Default` – open-source build, no Firebase/billing
- `GooglePlay` – production build with Firebase auth, Firestore, and in-app billing

Most development and all unit tests run against the `Default` flavor.

## Building

Set `JAVA_HOME` to the Android Studio JDK before running Gradle commands:

```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
```

**Assemble debug APK:**
```bash
./gradlew assembleDefaultDebug
```

**Assemble release APK (Default flavor):**
```bash
./gradlew assembleDefaultRelease
```

## Running unit tests

Unit tests use **Robolectric** (JVM-based Android testing, no device required).

```bash
# Run all unit tests for the Default/Debug variant
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew testDefaultDebugUnitTest

# Force a fresh run (skip Gradle's UP-TO-DATE cache)
./gradlew testDefaultDebugUnitTest --rerun
```

**Test results:** `app/build/reports/tests/testDefaultDebugUnitTest/index.html`

### Running a single test class

```bash
./gradlew testDefaultDebugUnitTest --tests "com.parishod.watomatic.model.preferences.PreferencesManagerTest"
```

### Running a single test method

```bash
./gradlew testDefaultDebugUnitTest \
--tests "com.parishod.watomatic.model.preferences.PreferencesManagerTest.isServiceEnabled defaults to false"
```

## Running instrumentation tests (requires a device or emulator)

```bash
# Start an emulator first, then:
./gradlew connectedDefaultDebugAndroidTest
```

**Test results:** `app/build/reports/androidTests/connected/index.html`

## Generating a coverage report (unit tests)

```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew jacocoUnitTestReport
```

**HTML report:** `app/build/reports/jacoco/jacocoUnitTestReport/html/index.html`
**XML report:** `app/build/reports/jacoco/jacocoUnitTestReport/jacocoUnitTestReport.xml`

> **Note on coverage numbers:** The overall project number in JaCoCo is low (~3%) because it
> includes all classes (Activities, Fragments, Services) that cannot be unit-tested. Additionally,
> Robolectric tests do not contribute to JaCoCo's offline-instrumentation coverage because
> Robolectric's sandbox class loader strips JaCoCo probe calls. Pure JUnit4 tests (e.g.
> `NetworkModelsTest`) report correctly at 100%.
>
> The **actual test coverage** of testable model/utility classes is estimated at ~90%:
> | Class | Tests | Est. coverage |
> |---|---|---|
> | `PreferencesManager.java` (737 lines) | 80 | ~85% |
> | `CustomRepliesData.java` (164 lines) | 17 | ~90% |
> | `NotificationUtils.java` (254 lines) | 22 | ~75% |
> | `Constants.kt` (54 lines) | 20 | ~100% |
> | `AppUtils.java` (38 lines) | 2 | ~70% |
> | `MessageLog.java` (117 lines) | 18 | ~100% |
> | `GithubReleaseNotes.java` (82 lines) | 8 | ~90% |
> | Network models (~330 lines) | 45 | ~100% |

## Test architecture

| Test type | Runner | Location | Use for |
|---|---|---|---|
| Unit (JVM) | JUnit4 | `src/test/` | Pure logic, no Android |
| Unit (Android) | Robolectric | `src/test/` | Classes that need Context, SharedPreferences, etc. |
| Instrumentation | AndroidJUnit4 | `src/androidTest/` | Real device: UI, Keystore, etc. |

**Key test files:**

| File | Tests | What it covers |
|---|---|---|
| `PreferencesManagerTest.kt` | 80 | All preference flags, subscription state, AI settings, locale parsing |
| `ConstantsTest.kt` | 20 | Supported apps list, URLs, AI constants |
| `CustomRepliesDataTest.kt` | 17 | Reply validation, set/get, history limit |
| `NotificationUtilsTest.kt` | 22 | `isNewNotification`, `getTitle`, `extractWearNotification` |
| `NetworkModelsTest.kt` | 45 | All OpenAI/Atomatic request/response POJOs |
| `MessageLogTest.kt` | 18 | Room entity constructor, getters/setters |
| `GithubReleaseNotesTest.kt` | 8 | Parcelable serialization |
| `MainActivityTest.kt` | 5 | Activity launch, key UI elements visible |
| `PreferencesManagerInstrumentedTest.kt` | 11 | Real-device SharedPreferences + Keystore |

**Test isolation:** `PreferencesManager` and `CustomRepliesData` are singletons. Both expose
`@VisibleForTesting resetInstance()` methods. Tests call these in `@Before`/`@After` and also
clear SharedPreferences directly to guarantee a fresh state.

**Robolectric config:** All Robolectric test classes use `@Config(sdk = [28])` to avoid
resource-resolution failures with newer SDK levels.

## Dependencies (key test libs)

| Library | Version | Purpose |
|---|---|---|
| `junit` | 4.x | Test runner and assertions |
| `robolectric` | 4.15.1 | Android JVM testing |
| `mockito-kotlin` | 5.4.0 | Kotlin-idiomatic mocking |
| `androidx.test.core` | latest | `ApplicationProvider.getApplicationContext()` |
| `espresso-core` | latest | Instrumentation UI assertions |

## Common issues

**`JAVA_HOME` not found / `java: command not found`**
```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
```

**`Resources$NotFoundException` in Robolectric tests**
- All Robolectric test classes must have `@Config(sdk = [28])`.
- Production code (PreferencesManager, CustomRepliesData) wraps `getString(R.string.*)` calls
in try-catch and falls back to hardcoded defaults when resources are unavailable.

**`NoClassDefFoundError` / `ExceptionInInitializerError` for `KeyGenParameterSpec`**
- The Android Keystore hardware is unavailable in JVM test environments.
- `PreferencesManager` catches both `Exception` and `Error` when initializing
`EncryptedSharedPreferences`, falling back to `_encryptedSharedPrefs = null`.
Tests that call `getOpenAIApiKey()` will get `null` and should handle that gracefully.

**Tests passing locally but not in CI**
- Ensure the CI image has Android SDK with API 28 platform installed.
- Use the `Default` flavor for CI (`testDefaultDebugUnitTest`), not `GooglePlay`
(which requires `google-services.json`).
41 changes: 41 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
id("kotlin-android")
alias(libs.plugins.google.ksp)
id("kotlin-parcelize")
id("jacoco")
}

android {
Expand Down Expand Up @@ -34,6 +35,9 @@ android {
}

buildTypes {
getByName("debug") {
enableUnitTestCoverage = true
}
getByName("release") {
// Enables code shrinking, obfuscation, and optimization for only
// your project's release build type.
Expand Down Expand Up @@ -83,6 +87,7 @@ dependencies {
implementation(libs.activity)
testImplementation(libs.junit)
testImplementation(libs.mockito.core)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.robolectric)
testImplementation(libs.test.core)
androidTestImplementation(libs.ext.junit)
Expand Down Expand Up @@ -125,4 +130,40 @@ gradle.startParameter.taskNames.any { task ->
} else {
false
}
}

// JaCoCo unit test coverage report for the Default/Debug variant
tasks.register<JacocoReport>("jacocoUnitTestReport") {
dependsOn("testDefaultDebugUnitTest")
group = "Reporting"
description = "Generates JaCoCo unit test coverage report for DefaultDebug variant."

reports {
xml.required.set(true)
html.required.set(true)
}

val excludes = listOf(
"**/R.class", "**/R\$*.class",
"**/BuildConfig.*", "**/Manifest*.*",
"**/*Test*.*", "android/**/*.*",
"**/databinding/**", "**/*_MembersInjector.class",
"**/*_Factory.class", "**/*Directions*.*",
"**/*\$\$serializer.class"
)

val javaClasses = fileTree("${layout.buildDirectory.get()}/intermediates/javac/DefaultDebug/compileDefaultDebugJavaWithJavac/classes") {
exclude(excludes)
}
val kotlinClasses = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/DefaultDebug") {
exclude(excludes)
}

sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
classDirectories.setFrom(files(javaClasses, kotlinClasses))
executionData.setFrom(
fileTree(layout.buildDirectory.get()) {
include("**/*.exec", "**/*.ec")
}
)
}
Loading
Loading