Skip to content
Open
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
69 changes: 55 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ This notification listener app acts as a bridge between your Android device's no

## Features

- Captures notifications as they appear
- Rule-based filtering by package name, title regex, and text regex
- Sends structured JSON data to webhook endpoints
- Manual retry for failed or undecided notifications
- **Real-time notification capture** - Listens to Android system notifications as they appear
- **Smart filtering system** - Rule-based filtering by package name with regex support
- **Webhook integration** - Sends structured JSON data to configurable webhook endpoints
- **Offline resilience** - Stores failed notifications locally for manual retry
- **Undecided notification handling** - Captures notifications that don't match any rules for manual processing

## Technical Architecture

Expand All @@ -19,7 +20,9 @@ graph TD
B --> C[Service Layer<br/>NotificationListenerService]
C --> D[Repository Layer<br/>NotificationRepository]
D --> E[Network Layer<br/>WebhookApi]
D --> F[Database Layer<br/>AppDatabase]
D --> F[Database Layer<br/>FailedNotification + UndecidedNotification]
C --> G[Configuration Layer<br/>NotificationFilterEngine]
G --> H[Config Data<br/>DefaultWebhookConfig]
```

### Data Flow
Expand All @@ -31,19 +34,22 @@ flowchart TD
C --> D[NotificationFilterEngine.isIgnored]

D --> E{Is Ignored?}
E -->|Yes| F[Skip Notification]
E -->|Yes| F[Skip Notification<br/>Black-holed]
E -->|No| G[NotificationFilterEngine.findMatchingUrls]

G --> H{Has Matching URLs?}
H -->|No| I[Store as Undecided Notification]
H -->|Yes| J[Send to Webhook URLs]
H -->|No| I[Store as Undecided Notification<br/>Room Database]
H -->|Yes| J[Send to Webhook URLs<br/>Retrofit HTTP POST]

J --> K{Send Successful?}
K -->|Yes| L[Continue Processing]
K -->|No| M[Store as Failed Notification]
K -->|Yes| L[Continue Processing<br/>Success]
K -->|No| M[Store as Failed Notification<br/>Room Database]

I --> N[Available for Manual Upload]
M --> O[Available for Retry]
I --> N[Available for Manual Upload<br/>NotificationListActivity]
M --> O[Available for Retry<br/>NotificationListActivity]

N --> P[User Selects Webhook URL<br/>Manual Processing]
O --> Q[User Retries Failed Request<br/>Bulk Operations]
```

## JSON Payload Format
Expand All @@ -63,16 +69,38 @@ Notifications are sent to webhooks as JSON with the following structure:

## Installation & Setup

1. Build the APK:
### Prerequisites

- **Android 15+ (API level 35+)** - The app requires the latest Android version
- **Android Studio** - For development and building
- **ADB** - For installing the APK

### Build & Install

1. **Configure webhook URL** (optional):

```bash
export WEBHOOK_URL_BANK="https://your-n8n-instance.com/webhook/your-webhook-id"
```

2. **Build the APK**:

```bash
# Debug build
./gradlew assembleDebug

# Release build (requires keystore configuration)
./gradlew assembleRelease
```

2. Install on device (Android 15+):
3. **Install on device**:

```bash
# Debug APK
adb install app/build/outputs/apk/debug/app-debug.apk

# Release APK
adb install app/build/outputs/apk/release/app-release.apk
```

3. Grant Notification Access:
Expand All @@ -84,6 +112,18 @@ adb install app/build/outputs/apk/debug/app-debug.apk

## Development

### Technology Stack

- **Language**: Kotlin
- **UI Framework**: Jetpack Compose
- **Architecture**: MVVM with Repository Pattern
- **Dependency Injection**: Hilt
- **Database**: Room (SQLite)
- **Networking**: Retrofit + OkHttp
- **JSON Serialization**: Gson
- **Testing**: JUnit 4, MockK, Coroutines Test
- **Build System**: Gradle with Kotlin DSL

### Project Structure

```
Expand All @@ -104,5 +144,6 @@ app/src/main/java/com/daohoangson/n8n/notificationlistener/
The project includes comprehensive unit tests:

```bash
# Run all unit tests
./gradlew test
```
21 changes: 18 additions & 3 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,30 @@ android {
versionName = "1.0"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

buildConfigField(
"String",
"WEBHOOK_URL_BANK",
System.getenv("WEBHOOK_URL_BANK") ?: "\"https://n8n.cloud/webhook/bank\""
)
}

signingConfigs {
create("release") {
storeFile = file("keystore.jks")
storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
keyAlias = System.getenv("ANDROID_KEY_ALIAS")
keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
}
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Function parameters should be placed on separate lines when there are multiple parameters for better readability and maintainability.

Suggested change
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"

Copilot uses AI. Check for mistakes.
)
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
Expand Down Expand Up @@ -76,7 +91,7 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.room.testing)
testImplementation(libs.okhttp.mockwebserver)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,45 +6,20 @@ import javax.inject.Singleton

@Singleton
class NotificationFilterEngine @Inject constructor() {

private val config = DefaultWebhookConfig.config

fun isIgnored(notificationData: NotificationData): Boolean {
return config.ignoredPackages.contains(notificationData.packageName)
}

fun findMatchingUrls(notificationData: NotificationData): List<WebhookUrl> {
return config.urls.filter { webhookUrl ->
webhookUrl.rules.any { rule ->
matchesRule(notificationData, rule)
}
return config.ignoredPackages.any { regex ->
regex.matches(notificationData.packageName)
}
}

private fun matchesRule(notificationData: NotificationData, rule: FilterRule): Boolean {
if (rule.packageName != notificationData.packageName) {
return false
}

if (rule.titleRegex == null && rule.textRegex == null) {
return true
}

rule.titleRegex?.let { titleRegex ->
val title = notificationData.title ?: ""
if (!titleRegex.matches(title)) {
return false
}
}

rule.textRegex?.let { textRegex ->
val text = notificationData.text ?: ""
if (!textRegex.matches(text)) {
return false

fun findMatchingUrls(notificationData: NotificationData): List<WebhookUrl> {
return config.urls.filter {
it.packages.any { regex ->
regex.matches(notificationData.packageName)
}
}

return true
}

}
Original file line number Diff line number Diff line change
@@ -1,64 +1,58 @@
package com.daohoangson.n8n.notificationlistener.config

import com.daohoangson.n8n.notificationlistener.BuildConfig

data class WebhookConfig(
val urls: List<WebhookUrl>,
val ignoredPackages: List<String>
val urls: List<WebhookUrl>, val ignoredPackages: List<Regex>
)

data class WebhookUrl(
val url: String,
val name: String,
val rules: List<FilterRule>
)

data class FilterRule(
val packageName: String,
val titleRegex: Regex? = null,
val textRegex: Regex? = null
val url: String, val name: String, val packages: List<Regex>
Comment on lines +6 to +10
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Data class parameters should be placed on separate lines when there are multiple parameters for better readability and maintainability.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Data class parameters should be placed on separate lines when there are multiple parameters for better readability and maintainability.

Suggested change
val url: String, val name: String, val packages: List<Regex>
val url: String,
val name: String,
val packages: List<Regex>

Copilot uses AI. Check for mistakes.
)

object DefaultWebhookConfig {
val config = WebhookConfig(
urls = listOf(
WebhookUrl(
url = "https://n8n.cloud/webhook/slack-notifications",
name = "Slack Notifications",
rules = listOf(
FilterRule(packageName = "com.slack"),
FilterRule(packageName = "com.microsoft.teams")
)
),
WebhookUrl(
url = "https://n8n.cloud/webhook/social-media",
name = "Social Media",
rules = listOf(
FilterRule(packageName = "com.instagram.android"),
FilterRule(packageName = "com.twitter.android"),
FilterRule(
packageName = "com.facebook.katana",
titleRegex = ".*mentioned you.*".toRegex()
)
url = BuildConfig.WEBHOOK_URL_BANK, name = "Bank apps", packages = listOf(
Regex.fromLiteral("com.bplus.vtpay"),
Regex.fromLiteral("com.evnhcmc.evnmobileapp"),
Regex.fromLiteral("com.mservice.momotransfer"),
Regex.fromLiteral("com.VCB"),
Regex.fromLiteral("com.vib.myvib2"),
Regex.fromLiteral("vn.com.techcombank.bb.app"),
Regex.fromLiteral("vn.com.vng.zalopay")
)
),
WebhookUrl(
url = "https://n8n.cloud/webhook/urgent-alerts",
name = "Urgent Alerts",
rules = listOf(
FilterRule(
packageName = "com.android.phone",
titleRegex = ".*Emergency.*".toRegex()
),
FilterRule(
packageName = "com.banking.app",
textRegex = ".*fraud.*|.*suspicious.*".toRegex(RegexOption.IGNORE_CASE)
)
)
)
),
ignoredPackages = listOf(
"com.android.systemui",
"com.google.android.gms",
"com.android.providers.downloads"
), ignoredPackages = listOf(
// chat
Regex.fromLiteral("com.discord"),
Regex.fromLiteral("com.facebook.orca"),
Regex.fromLiteral("com.Slack"),
Regex.fromLiteral("org.telegram.messenger"),
Regex.fromLiteral("org.twitter.android"),
Regex.fromLiteral("com.whatsapp"),
Regex.fromLiteral("com.zing.zalo"),
// social
Regex.fromLiteral("com.facebook.katana"),
Regex("^com\\.instagram.*"),
Regex.fromLiteral("com.linkedin.android"),
// system
Regex.fromLiteral("android"),
Regex("^com\\.android.*"),
Regex("^com\\.google.*"),
Regex("^com\\.osp.*"),
Regex("^com\\.samsung.*"),
Regex("^com\\.sec.*"),
// others
Regex.fromLiteral("com.echo.global.app"),
Regex.fromLiteral("com.glow.android.baby"),
Regex.fromLiteral("com.grabtaxi.passenger"),
Regex.fromLiteral("com.microsoft.office.outlook"),
Regex("^com.netflix.*"),
Regex.fromLiteral("com.nordvpn.android"),
Regex.fromLiteral("com.openai.chatgpt"),
Regex.fromLiteral("com.viettel.ViettelPost"),
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,18 @@ import com.daohoangson.n8n.notificationlistener.utils.Constants
abstract class AppDatabase : RoomDatabase() {
abstract fun failedNotificationDao(): FailedNotificationDao
abstract fun undecidedNotificationDao(): UndecidedNotificationDao

companion object {
@Volatile
private var INSTANCE: AppDatabase? = null

fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
Constants.DATABASE_NAME
).fallbackToDestructiveMigration()
).fallbackToDestructiveMigration(dropAllTables = true)
.build()
INSTANCE = instance
instance
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ object NetworkModule {
.build()

private val retrofit = Retrofit.Builder()
.baseUrl("https://google.com")
Copy link

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hardcoded base URL 'https://google.com' appears to be a placeholder or debugging value. This should be replaced with a proper configuration or removed if not needed.

Copilot uses AI. Check for mistakes.
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
Expand Down
Loading
Loading