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
33 changes: 33 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
name: Bug report
about: Create a report to help us improve
title: '[BUG] '
labels: bug
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots/Recordings**
If applicable, add screenshots or screen recordings to help explain your problem.

**Smartphone (please complete the following information):**
- Device: [e.g. Pixel 8]
- OS Version: [e.g. Android 14]
- Hush Version: [e.g. v1.0.0]
- AICore / Gemini Nano model status (downloaded/not downloaded):

**Additional context/Logs**
Add any other context or logcat output here.
20 changes: 20 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: '[FEATURE] '
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]

**Describe the solution you'd like**
A clear and concise description of what you want to happen.

**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.

**Additional context**
Add any other context, mockup drawings, or screenshots about the feature request here.
25 changes: 25 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
## Description
Describe the changes you made and the rationale behind them. Include context, screenshots/videos if relevant, and details about the implementation.

## Related Issue
Fixes # (issue number)

## Type of Change
Please delete options that are not relevant:
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Chore / Documentation / Refactoring

## How Has This Been Tested?
Please describe the tests that you ran to verify your changes:
- [ ] Unit tests (e.g. `./gradlew testDebugUnitTest`)
- [ ] Instrumented tests (e.g. `./gradlew connectedAndroidTest`)
- [ ] Manual testing (please describe target device and scenario)

## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] My changes generate no new warnings
- [ ] New and existing unit tests pass locally with my changes
68 changes: 68 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Contributing to Hush

First off, thank you for taking the time to contribute! Contributions from the community help make Hush better for everyone.

Here is a guide to help you get started with contributing.

---

## How Can I Contribute?

### 1. Reporting Bugs
* Check the [Issues](https://github.com/vssinghh/hush/issues) tab to see if the bug has already been reported.
* If not, open a new issue using the **Bug Report** template.
* Include clear steps to reproduce the issue, and if possible, logs (logcat) or screenshots.

### 2. Suggesting Features
* Open an issue using the **Feature Request** template.
* Explain the feature's value and how it fits Hush's privacy-first on-device AI model.

### 3. Submitting Pull Requests
* Fork the repository and create your branch from `main`.
* If you're fixing a bug or adding a feature, please link it to an existing open issue.
* Keep your commits focused and write descriptive commit messages.
* Ensure all tests pass before submitting.

---

## Development Setup

### Prerequisites
* **JDK 17**
* **Android SDK Platform 35**
* A device supporting **Gemini Nano** (Pixel 6+ or similar with AICore initialized).

### Building the Project
You can build the project from the command line:

```bash
# Clone your fork
git clone https://github.com/YOUR-USERNAME/hush.git
cd hush

# Build debug APK
./gradlew assembleDebug
```

### Running Tests
Make sure unit tests pass before you submit code:

```bash
# Run unit tests
./gradlew testDebugUnitTest

# Run instrumented tests (requires connected device/emulator)
./gradlew connectedAndroidTest
```

---

## Code Style & Architecture
Hush follows **Clean Architecture** principles:
* **Domain Layer**: Contains use cases and pure interfaces. No Android dependencies.
* **Data Layer**: Room DB, Gemini Nano integration, and repository implementations.
* **UI Layer**: Jetpack Compose and ViewModels.

Please follow standard Kotlin coding conventions and formatting. Keep architecture boundaries clean (e.g. do not leak UI/Android components into the Domain layer).

Thank you for supporting open source!
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ I built Hush because Android's notification settings are buried and rigid. With

- **Natural language rules.** Tell Hush *"Mute WhatsApp notifications except from Bob"* and it builds the rule for you.
- **Voice input.** Tap the mic, say what you want. A live waveform confirms it's listening.
- **Block, Mute, or Allow** — three actions per rule.
- **Block, Mute, or Allow** — three actions per rule. Blocked notifications are dismissed instantly; muted ones are snoozed out of your shade.
- **Works everywhere.** On devices without Gemini Nano, a built-in deterministic parser handles common commands — no AI required.
- **Inverted / exception rules.** *"Block all from Gmail except @company.com"* works exactly how you'd expect.
- **Time windows** so rules only fire during certain hours (e.g., 10 PM – 7 AM).
- Full **notification history** — see what got filtered and which rule matched.
Expand Down
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ tasks.withType<JavaCompile>().configureEach {


dependencies {
constraints {
// androidx.test 1.7/espresso 3.7 need these transitively; lift the main
// runtime graph so AGP's consistent resolution doesn't conflict.
implementation("androidx.tracing:tracing:1.1.0")
implementation("androidx.concurrent:concurrent-futures:1.2.0")
}
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
Expand All @@ -105,6 +111,7 @@ dependencies {
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling)

// Navigation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,11 @@ class AppFoundationE2ETest {
onboardingPrefs.isOnboardingCompleted = true
recreateActivityAndWait("ai_unsupported_banner")

// Expected Result: Persistent banner is shown and chat buttons are disabled
// Expected Result: Persistent banner is shown; chat stays usable via
// the built-in fallback parser (basic mode)
composeRule.onNodeWithTag("ai_unsupported_banner").assertIsDisplayed()
composeRule.onNodeWithTag("chat_send_button").assertIsNotEnabled()
composeRule.onNodeWithTag("chat_mic_button").assertIsNotEnabled()
composeRule.onNodeWithTag("chat_send_button").assertIsEnabled()
composeRule.onNodeWithTag("chat_mic_button").assertIsEnabled()
}

@Test
Expand All @@ -304,6 +305,7 @@ class AppFoundationE2ETest {
fun testOnboarding_BatteryOptimizationRejected_AllowsProgressWithWarning() {
// T2_F1_05: Verify battery optimization denial does not block onboarding
onboardingPrefs.isOnboardingCompleted = false
(permissionManager as FakePermissionManager).grantBatteryOnRequest = false
recreateActivityAndWait("onboarding_screen")

// Go to onboarding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ class ConversationalAIE2ETest {
}

// Expected Result: Chat bubble and proposed rule card displayed
composeRule.onNodeWithText("Mute WhatsApp").assertIsDisplayed()
// ("Mute WhatsApp" appears both as the user's bubble and as the card's
// rule summary, so assert on the first match.)
composeRule.onAllNodesWithText("Mute WhatsApp").onFirst().assertIsDisplayed()
composeRule.onNodeWithTag("ai_rule_card").assertIsDisplayed()
}

Expand Down
10 changes: 7 additions & 3 deletions app/src/androidTest/java/com/hush/app/e2e/CrossFeatureE2ETest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,13 @@ class CrossFeatureE2ETest {
)
logDao.insertLog(log)

// Delete the rule
// Delete the rule (swipe now asks for confirmation)
composeRule.onNodeWithTag("bottom_nav_rules").performClick()
composeRule.onNodeWithTag("rule_card_206").performTouchInput { swipeLeft() }
composeRule.waitUntil(10000) {
composeRule.onAllNodesWithTag("rule_delete_confirm_button").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("rule_delete_confirm_button").performClick()

// Wait for rule card deletion in UI/DB
composeRule.waitUntil(10000) {
Expand All @@ -371,9 +375,9 @@ class CrossFeatureE2ETest {
}
composeRule.onNodeWithText("Rule deletion test").performClick()

// Expected Result: Detail renders fallback placeholder "Rule deleted" instead of crashing
// Expected Result: Detail renders a "(deleted)" fallback instead of crashing
composeRule.onNodeWithTag("history_detail_dialog").assertIsDisplayed()
composeRule.onNodeWithText("Triggered by Rule: Rule deleted").assertIsDisplayed()
composeRule.onNodeWithText("Temp Rule (deleted)").assertIsDisplayed()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,17 @@ class RuleManagementHistoryE2ETest {
// Open Rules screen
composeRule.onNodeWithTag("bottom_nav_rules").performClick()

// Swipe left on the rule card
// Swipe left on the rule card — this now asks for confirmation
composeRule.onNodeWithTag("rule_card_102").performTouchInput {
swipeLeft()
}

// Confirm deletion in the dialog
composeRule.waitUntil(10000) {
composeRule.onAllNodesWithTag("rule_delete_confirm_button").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("rule_delete_confirm_button").performClick()

// Verify the rule card is removed from UI and DB
composeRule.onNodeWithTag("rule_card_102").assertDoesNotExist()
val deletedRule = ruleDao.getRuleById(102L)
Expand Down Expand Up @@ -196,7 +202,7 @@ class RuleManagementHistoryE2ETest {
// Expected Result: Detail dialog opens
composeRule.onNodeWithTag("rule_detail_dialog").assertIsDisplayed()
composeRule.onNode(hasText("Mute WhatsApp") and hasAnyAncestor(hasTestTag("rule_detail_dialog"))).assertIsDisplayed()
composeRule.onNodeWithText("Package: com.whatsapp").assertIsDisplayed()
composeRule.onNode(hasText("WhatsApp") and hasAnyAncestor(hasTestTag("rule_detail_dialog"))).assertIsDisplayed()

// Dismiss dialog
composeRule.onNodeWithText("Close").performClick()
Expand All @@ -215,20 +221,31 @@ class RuleManagementHistoryE2ETest {
// Open History screen
composeRule.onNodeWithTag("bottom_nav_history").performClick()

// Wait for the Room flow to emit before counting
composeRule.waitUntil(10000) {
composeRule.onAllNodesWithText("Blocked text").fetchSemanticsNodes().isNotEmpty()
}

// Verify all 3 items are present
composeRule.onNodeWithTag("history_list").onChildren().assertCountEquals(3)

// Click "Blocked" tab
composeRule.onNodeWithTag("history_tab_blocked").performClick()

// Verify only blocked item is visible
composeRule.waitUntil(10000) {
composeRule.onAllNodesWithText("Allowed text").fetchSemanticsNodes().isEmpty()
}
composeRule.onNodeWithText("Blocked text").assertIsDisplayed()
composeRule.onNodeWithText("Allowed text").assertDoesNotExist()

// Click "All" tab
composeRule.onNodeWithTag("history_tab_all").performClick()

// Verify all 3 visible
composeRule.waitUntil(10000) {
composeRule.onAllNodesWithText("Allowed text").fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("history_list").onChildren().assertCountEquals(3)
}

Expand Down Expand Up @@ -265,7 +282,7 @@ class RuleManagementHistoryE2ETest {

// Expected Result: Detail modal opens, showing rule name
composeRule.onNodeWithTag("history_detail_dialog").assertIsDisplayed()
composeRule.onNodeWithText("Triggered by Rule: Block Spam").assertIsDisplayed()
composeRule.onNode(hasText("Block Spam") and hasAnyAncestor(hasTestTag("history_detail_dialog"))).assertIsDisplayed()

// Dismiss dialog
composeRule.onNodeWithText("Close").performClick()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ class FakePermissionManager @Inject constructor(
var batteryExempt = false
var notificationDenied = false

/** Set false to simulate the user declining the battery-exemption dialog. */
var grantBatteryOnRequest = true

private val prefs by lazy {
context.getSharedPreferences("hush_preferences", Context.MODE_PRIVATE)
}
Expand All @@ -41,7 +44,9 @@ class FakePermissionManager @Inject constructor(
}

override fun requestBatteryExemption(context: Context) {
batteryExempt = true
if (grantBatteryOnRequest) {
batteryExempt = true
}
}

override fun setNotificationAccessDenied(denied: Boolean) {
Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/com/hush/app/domain/model/ChatMessage.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.hush.app.domain.model

import java.time.LocalTime

enum class ChatRole {
USER, ASSISTANT
}

data class ChatMessage(
val text: String,
val role: ChatRole,
val time: LocalTime = LocalTime.now()
)
3 changes: 2 additions & 1 deletion app/src/main/java/com/hush/app/domain/model/ParsedCommand.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ data class ParsedCommand(
val isInverted: Boolean,
val timeStart: LocalTime?,
val timeEnd: LocalTime?,
val summary: String
val summary: String,
val originalPrompt: String? = null
)
Loading
Loading