diff --git a/README.md b/README.md index 451a625..9f9d437 100644 --- a/README.md +++ b/README.md @@ -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 @@ -19,7 +20,9 @@ graph TD B --> C[Service Layer
NotificationListenerService] C --> D[Repository Layer
NotificationRepository] D --> E[Network Layer
WebhookApi] - D --> F[Database Layer
AppDatabase] + D --> F[Database Layer
FailedNotification + UndecidedNotification] + C --> G[Configuration Layer
NotificationFilterEngine] + G --> H[Config Data
DefaultWebhookConfig] ``` ### Data Flow @@ -31,19 +34,22 @@ flowchart TD C --> D[NotificationFilterEngine.isIgnored] D --> E{Is Ignored?} - E -->|Yes| F[Skip Notification] + E -->|Yes| F[Skip Notification
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
Room Database] + H -->|Yes| J[Send to Webhook URLs
Retrofit HTTP POST] J --> K{Send Successful?} - K -->|Yes| L[Continue Processing] - K -->|No| M[Store as Failed Notification] + K -->|Yes| L[Continue Processing
Success] + K -->|No| M[Store as Failed Notification
Room Database] - I --> N[Available for Manual Upload] - M --> O[Available for Retry] + I --> N[Available for Manual Upload
NotificationListActivity] + M --> O[Available for Retry
NotificationListActivity] + + N --> P[User Selects Webhook URL
Manual Processing] + O --> Q[User Retries Failed Request
Bulk Operations] ``` ## JSON Payload Format @@ -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: @@ -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 ``` @@ -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 ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3c2fb92..5d9b9ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" ) + signingConfig = signingConfigs.getByName("release") } } compileOptions { @@ -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)) diff --git a/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngine.kt b/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngine.kt index 03cb541..d5b45b4 100644 --- a/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngine.kt +++ b/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngine.kt @@ -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 { - 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 { + return config.urls.filter { + it.packages.any { regex -> + regex.matches(notificationData.packageName) } } - - return true } - } \ No newline at end of file diff --git a/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/WebhookConfig.kt b/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/WebhookConfig.kt index a212283..b275164 100644 --- a/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/WebhookConfig.kt +++ b/app/src/main/java/com/daohoangson/n8n/notificationlistener/config/WebhookConfig.kt @@ -1,64 +1,58 @@ package com.daohoangson.n8n.notificationlistener.config +import com.daohoangson.n8n.notificationlistener.BuildConfig + data class WebhookConfig( - val urls: List, - val ignoredPackages: List + val urls: List, val ignoredPackages: List ) data class WebhookUrl( - val url: String, - val name: String, - val rules: List -) - -data class FilterRule( - val packageName: String, - val titleRegex: Regex? = null, - val textRegex: Regex? = null + val url: String, val name: String, val packages: List ) 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"), ) ) } \ No newline at end of file diff --git a/app/src/main/java/com/daohoangson/n8n/notificationlistener/data/database/AppDatabase.kt b/app/src/main/java/com/daohoangson/n8n/notificationlistener/data/database/AppDatabase.kt index e87b8c5..841919b 100644 --- a/app/src/main/java/com/daohoangson/n8n/notificationlistener/data/database/AppDatabase.kt +++ b/app/src/main/java/com/daohoangson/n8n/notificationlistener/data/database/AppDatabase.kt @@ -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 diff --git a/app/src/main/java/com/daohoangson/n8n/notificationlistener/network/NetworkModule.kt b/app/src/main/java/com/daohoangson/n8n/notificationlistener/network/NetworkModule.kt index 1d16733..9bfe774 100644 --- a/app/src/main/java/com/daohoangson/n8n/notificationlistener/network/NetworkModule.kt +++ b/app/src/main/java/com/daohoangson/n8n/notificationlistener/network/NetworkModule.kt @@ -20,6 +20,7 @@ object NetworkModule { .build() private val retrofit = Retrofit.Builder() + .baseUrl("https://google.com") .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() diff --git a/app/src/test/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngineTest.kt b/app/src/test/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngineTest.kt index 8941bc0..e97e881 100644 --- a/app/src/test/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngineTest.kt +++ b/app/src/test/java/com/daohoangson/n8n/notificationlistener/config/NotificationFilterEngineTest.kt @@ -8,9 +8,13 @@ class NotificationFilterEngineTest { private val filterEngine = NotificationFilterEngine() + // ======================================== + // Tests for isIgnored() method + // ======================================== + @Test - fun isIgnored_shouldReturnTrue_forIgnoredPackages() { - val notificationData = NotificationData( + fun isIgnored_shouldReturnTrue_forSystemPackages() { + val systemNotification = NotificationData( packageName = "com.android.systemui", title = "Test Title", text = "Test Message", @@ -18,207 +22,205 @@ class NotificationFilterEngineTest { id = 1, tag = null ) - - assertTrue(filterEngine.isIgnored(notificationData)) + + assertTrue(filterEngine.isIgnored(systemNotification)) } @Test - fun isIgnored_shouldReturnFalse_forNonIgnoredPackages() { - val notificationData = NotificationData( - packageName = "com.slack", + fun isIgnored_shouldReturnTrue_forGoogleServicesPackages() { + val gmsNotification = NotificationData( + packageName = "com.google.android.gms", title = "Test Title", text = "Test Message", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - assertFalse(filterEngine.isIgnored(notificationData)) + + assertTrue(filterEngine.isIgnored(gmsNotification)) } @Test - fun findMatchingUrls_shouldReturnEmpty_forIgnoredPackages() { - val notificationData = NotificationData( - packageName = "com.google.android.gms", + fun isIgnored_shouldReturnTrue_forSocialMediaApps() { + // Test Slack + val slackNotification = NotificationData( + packageName = "com.Slack", title = "Test Title", text = "Test Message", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertTrue(matchingUrls.isEmpty()) - } + assertTrue(filterEngine.isIgnored(slackNotification)) - @Test - fun findMatchingUrls_shouldReturnMatchingUrl_forPackageNameMatch() { - val notificationData = NotificationData( - packageName = "com.slack", - title = "Test Title", + // Test Facebook Messenger + val messengerNotification = NotificationData( + packageName = "com.facebook.orca", + title = "John mentioned you in a comment", text = "Test Message", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertEquals(1, matchingUrls.size) - assertEquals("Slack Notifications", matchingUrls[0].name) - } + assertTrue(filterEngine.isIgnored(messengerNotification)) - @Test - fun findMatchingUrls_shouldReturnEmpty_forNonMatchingPackage() { - val notificationData = NotificationData( - packageName = "com.nonexistent.app", - title = "Test Title", + // Test Facebook + val facebookNotification = NotificationData( + packageName = "com.facebook.katana", + title = "John posted a photo", text = "Test Message", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertTrue(matchingUrls.isEmpty()) - } + assertTrue(filterEngine.isIgnored(facebookNotification)) - @Test - fun findMatchingUrls_shouldMatchTitleRegex() { - val notificationData = NotificationData( - packageName = "com.facebook.katana", - title = "John mentioned you in a comment", - text = "Test Message", + // Test Discord + val discordNotification = NotificationData( + packageName = "com.discord", + title = "New message", + text = "You have a new direct message", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertEquals(1, matchingUrls.size) - assertEquals("Social Media", matchingUrls[0].name) - } + assertTrue(filterEngine.isIgnored(discordNotification)) - @Test - fun findMatchingUrls_shouldNotMatch_whenTitleRegexFails() { - val notificationData = NotificationData( - packageName = "com.facebook.katana", - title = "John posted a photo", - text = "Test Message", + // Test Telegram + val telegramNotification = NotificationData( + packageName = "org.telegram.messenger", + title = "Any title", + text = "Any text", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertTrue(matchingUrls.isEmpty()) + assertTrue(filterEngine.isIgnored(telegramNotification)) } + // ======================================== + // Tests for findMatchingUrls() method - Bank Apps + // ======================================== + @Test - fun findMatchingUrls_shouldMatchTextRegex_caseInsensitive() { - val notificationData = NotificationData( - packageName = "com.banking.app", + fun findMatchingUrls_shouldMatchBankApps() { + // Test VCB + val vcbNotification = NotificationData( + packageName = "com.VCB", title = "Security Alert", text = "FRAUD detected on your account", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertEquals(1, matchingUrls.size) - assertEquals("Urgent Alerts", matchingUrls[0].name) - } - - @Test - fun findMatchingUrls_shouldMatchTextRegex_suspicious() { - val notificationData = NotificationData( - packageName = "com.banking.app", - title = "Security Alert", - text = "Suspicious activity detected", + val vcbUrls = filterEngine.findMatchingUrls(vcbNotification) + assertEquals(1, vcbUrls.size) + assertEquals("Bank apps", vcbUrls[0].name) + + // Test Techcombank + val techcombankNotification = NotificationData( + packageName = "vn.com.techcombank.bb.app", + title = "- VND 100,000", + text = "Thẻ 4 .... .... 1234\\nChi tiêu: - VND 100,000 vào 01/01/2025 lúc 00:00:00 tại GOOGLE...\\nHạn mức khả dụng: VND 123,456,789", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertEquals(1, matchingUrls.size) - assertEquals("Urgent Alerts", matchingUrls[0].name) - } - - @Test - fun findMatchingUrls_shouldNotMatch_whenTextRegexFails() { - val notificationData = NotificationData( - packageName = "com.banking.app", - title = "Account Update", - text = "Your balance has been updated", + val techcombankUrls = filterEngine.findMatchingUrls(techcombankNotification) + assertEquals(1, techcombankUrls.size) + assertEquals("Bank apps", techcombankUrls[0].name) + + // Test MoMo + val momoNotification = NotificationData( + packageName = "com.mservice.momotransfer", + title = "Payment Notification", + text = "You have received money", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertTrue(matchingUrls.isEmpty()) + val momoUrls = filterEngine.findMatchingUrls(momoNotification) + assertEquals(1, momoUrls.size) + assertEquals("Bank apps", momoUrls[0].name) + + // Test MyVIB + val vibNotification = NotificationData( + packageName = "com.vib.myvib2", + title = "Transaction Alert", + text = "Your account has been debited", + timestamp = System.currentTimeMillis(), + id = 1, + tag = null + ) + val vibUrls = filterEngine.findMatchingUrls(vibNotification) + assertEquals(1, vibUrls.size) + assertEquals("Bank apps", vibUrls[0].name) } + // ======================================== + // Tests for findMatchingUrls() method - Empty Results + // ======================================== + @Test - fun findMatchingUrls_shouldHandleNullTitle() { - val notificationData = NotificationData( - packageName = "com.facebook.katana", - title = null, + fun findMatchingUrls_shouldReturnEmpty_forNonMatchingPackages() { + val nonMatchingNotification = NotificationData( + packageName = "com.nonexistent.app", + title = "Test Title", text = "Test Message", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) + + val matchingUrls = filterEngine.findMatchingUrls(nonMatchingNotification) assertTrue(matchingUrls.isEmpty()) } @Test - fun findMatchingUrls_shouldHandleNullText() { - val notificationData = NotificationData( + fun findMatchingUrls_shouldReturnEmpty_forUnconfiguredBankApp() { + val unconfiguredBankNotification = NotificationData( packageName = "com.banking.app", - title = "Security Alert", - text = null, + title = "Account Update", + text = "Your balance has been updated", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) + + val matchingUrls = filterEngine.findMatchingUrls(unconfiguredBankNotification) assertTrue(matchingUrls.isEmpty()) } + // ======================================== + // Tests for findMatchingUrls() method - Edge Cases + // ======================================== + @Test - fun findMatchingUrls_shouldMatchMultipleUrls_forSamePackage() { - val notificationData = NotificationData( - packageName = "com.instagram.android", - title = "New message", - text = "You have a new direct message", + fun findMatchingUrls_shouldHandleNullTitleAndText() { + // Test null title + val nullTitleNotification = NotificationData( + packageName = "com.VCB", + title = null, + text = "Test Message", timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertEquals(1, matchingUrls.size) - assertEquals("Social Media", matchingUrls[0].name) - } + val nullTitleUrls = filterEngine.findMatchingUrls(nullTitleNotification) + assertEquals(1, nullTitleUrls.size) + assertEquals("Bank apps", nullTitleUrls[0].name) - @Test - fun findMatchingUrls_shouldMatch_withNoRegexRules() { - val notificationData = NotificationData( - packageName = "com.microsoft.teams", - title = "Any title", - text = "Any text", + // Test null text + val nullTextNotification = NotificationData( + packageName = "com.VCB", + title = "Security Alert", + text = null, timestamp = System.currentTimeMillis(), id = 1, tag = null ) - - val matchingUrls = filterEngine.findMatchingUrls(notificationData) - assertEquals(1, matchingUrls.size) - assertEquals("Slack Notifications", matchingUrls[0].name) + val nullTextUrls = filterEngine.findMatchingUrls(nullTextNotification) + assertEquals(1, nullTextUrls.size) + assertEquals("Bank apps", nullTextUrls[0].name) } } \ No newline at end of file