This commit is contained in:
nabeel
2026-04-19 15:02:55 +10:00
commit 3a94910acc
30 changed files with 1298 additions and 0 deletions

0
.gitignore vendored Normal file
View File

86
README.md Normal file
View File

@@ -0,0 +1,86 @@
# Notification Monitor
An Android app that lets you select installed apps and monitors all notifications they receive — displayed in a live, scrollable log.
---
## Features
- **App Selector** — browse all launchable apps on the device, search by name or package, and toggle monitoring per app (persisted across restarts)
- **Live notification log** — new notifications appear instantly at the top of the list, showing app name, title, body, and timestamp
- **Persistent history** — up to 200 entries are stored on-device and survive app restarts
- **Permission banner** — automatically detects if notification access has not been granted and prompts the user
- **Clear log** — one-tap menu option to wipe the history
- **Boot-safe** — `BootReceiver` ensures the service is re-bound by Android after device reboot
---
## How to Build
### Requirements
- Android Studio Hedgehog (2023.1.1) or later
- Android SDK 34
- Minimum device/emulator API 26 (Android 8.0)
### Steps
1. Open the project in Android Studio (`File → Open` → select the `NotificationMonitor` folder).
2. Let Gradle sync finish.
3. Run on a physical device or emulator (`Run → Run 'app'`).
---
## First-Time Setup on Device
Because `NotificationListenerService` is a privileged API, Android requires the user to manually grant access:
1. Launch the app — a dialog will appear immediately.
2. Tap **Go to Settings**.
3. Find **Notification Monitor** in the list and toggle it **ON**.
4. Confirm the system prompt.
5. Return to the app — the yellow warning banner disappears and monitoring is active.
Alternatively, reach this screen any time via the **⋮ menu → Notification Access Settings**.
---
## Project Structure
```
app/src/main/
├── AndroidManifest.xml
├── java/com/notificationmonitor/
│ ├── data/
│ │ ├── AppInfo.java — app metadata model
│ │ ├── NotificationEntry.java — captured notification model
│ │ └── PreferencesManager.java — SharedPreferences persistence
│ ├── service/
│ │ ├── NotificationMonitorService.java — core NotificationListenerService
│ │ └── BootReceiver.java — re-init after reboot
│ └── ui/
│ ├── MainActivity.java — notification log screen
│ ├── NotificationLogAdapter.java
│ ├── AppSelectorActivity.java — app picker screen
│ └── AppSelectorAdapter.java
└── res/
├── layout/
│ ├── activity_main.xml
│ ├── activity_app_selector.xml
│ ├── item_notification.xml
│ └── item_app.xml
├── menu/menu_main.xml
├── values/
│ ├── strings.xml
│ ├── colors.xml
│ └── themes.xml
└── drawable/ic_notification_bell.xml
```
---
## Key Technical Notes
- **`NotificationListenerService`** is the standard Android API for this use case. It requires the `BIND_NOTIFICATION_LISTENER_SERVICE` permission — this cannot be requested at runtime; it must be granted via the special Settings page.
- **Local broadcasts** (`LocalBroadcastManager`) are used to push live events from the service to the MainActivity without coupling them.
- **No third-party notification sniffing** — all data comes directly from Android's official API, so this is safe for personal use, but note that publishing an app with this permission to the Play Store requires Google approval.
- The log is capped at **200 entries** in `PreferencesManager` to avoid unbounded SharedPreferences growth.

41
app/build.gradle Normal file
View File

@@ -0,0 +1,41 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.notificationmonitor'
compileSdk 35
defaultConfig {
applicationId "com.notificationmonitor"
minSdk 26
targetSdk 35
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'androidx.core:core:1.13.1'
}

2
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,2 @@
# Add project specific ProGuard rules here.
-keep class com.notificationmonitor.** { *; }

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.NotificationMonitor">
<!-- Main Activity -->
<activity
android:name=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- App Selector Activity -->
<activity
android:name=".ui.AppSelectorActivity"
android:exported="false"
android:label="@string/select_apps" />
<!-- Notification Listener Service — requires special permission granted via Settings -->
<service
android:name=".service.NotificationMonitorService"
android:exported="true"
android:label="@string/service_label"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<!-- Boot receiver to restart monitoring after reboot -->
<receiver
android:name=".service.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

@@ -0,0 +1,26 @@
package com.notificationmonitor.data;
import android.graphics.drawable.Drawable;
/**
* Holds metadata about an installed app for display in the selector list.
*/
public class AppInfo {
private final String appName;
private final String packageName;
private final Drawable icon;
private boolean selected;
public AppInfo(String appName, String packageName, Drawable icon, boolean selected) {
this.appName = appName;
this.packageName = packageName;
this.icon = icon;
this.selected = selected;
}
public String getAppName() { return appName; }
public String getPackageName() { return packageName; }
public Drawable getIcon() { return icon; }
public boolean isSelected() { return selected; }
public void setSelected(boolean selected) { this.selected = selected; }
}

View File

@@ -0,0 +1,27 @@
package com.notificationmonitor.data;
/**
* Represents a captured notification entry shown in the log.
*/
public class NotificationEntry {
private final String appName;
private final String packageName;
private final String title;
private final String text;
private final long timestamp;
public NotificationEntry(String appName, String packageName,
String title, String text, long timestamp) {
this.appName = appName;
this.packageName = packageName;
this.title = title;
this.text = text;
this.timestamp = timestamp;
}
public String getAppName() { return appName; }
public String getPackageName() { return packageName; }
public String getTitle() { return title != null ? title : ""; }
public String getText() { return text != null ? text : ""; }
public long getTimestamp() { return timestamp; }
}

View File

@@ -0,0 +1,97 @@
package com.notificationmonitor.data;
import android.content.Context;
import android.content.SharedPreferences;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Centralised persistence layer using SharedPreferences.
* Stores the set of monitored package names and a rolling log of
* captured notifications (newest-first, capped at MAX_LOG_SIZE).
*/
public class PreferencesManager {
private static final String PREFS_NAME = "nm_prefs";
private static final String KEY_PACKAGES = "monitored_packages";
private static final String KEY_LOG = "notification_log";
private static final int MAX_LOG_SIZE = 200;
private final SharedPreferences prefs;
public PreferencesManager(Context context) {
prefs = context.getApplicationContext()
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
}
// ── Monitored packages ────────────────────────────────────────────────
public Set<String> getMonitoredPackages() {
return prefs.getStringSet(KEY_PACKAGES, new HashSet<>());
}
public void saveMonitoredPackages(Set<String> packages) {
prefs.edit().putStringSet(KEY_PACKAGES, packages).apply();
}
public boolean isMonitored(String packageName) {
return getMonitoredPackages().contains(packageName);
}
// ── Notification log ──────────────────────────────────────────────────
public List<NotificationEntry> getNotificationLog() {
List<NotificationEntry> entries = new ArrayList<>();
String json = prefs.getString(KEY_LOG, "[]");
try {
JSONArray arr = new JSONArray(json);
for (int i = 0; i < arr.length(); i++) {
JSONObject o = arr.getJSONObject(i);
entries.add(new NotificationEntry(
o.optString("appName"),
o.optString("packageName"),
o.optString("title"),
o.optString("text"),
o.optLong("timestamp")
));
}
} catch (JSONException ignored) {}
return entries;
}
public void addNotificationEntry(NotificationEntry entry) {
List<NotificationEntry> entries = getNotificationLog();
entries.add(0, entry); // newest first
if (entries.size() > MAX_LOG_SIZE) {
entries = entries.subList(0, MAX_LOG_SIZE);
}
saveLog(entries);
}
public void clearLog() {
prefs.edit().remove(KEY_LOG).apply();
}
private void saveLog(List<NotificationEntry> entries) {
JSONArray arr = new JSONArray();
for (NotificationEntry e : entries) {
JSONObject o = new JSONObject();
try {
o.put("appName", e.getAppName());
o.put("packageName", e.getPackageName());
o.put("title", e.getTitle());
o.put("text", e.getText());
o.put("timestamp", e.getTimestamp());
} catch (JSONException ignored) {}
arr.put(o);
}
prefs.edit().putString(KEY_LOG, arr.toString()).apply();
}
}

View File

@@ -0,0 +1,21 @@
package com.notificationmonitor.service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* Re-enables notification monitoring after device reboot.
* The NotificationListenerService itself is managed by the OS, but
* this receiver gives us a hook to do any app-level init on boot.
*/
public class BootReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
// The OS automatically re-binds NotificationListenerService
// if the user has granted access. Nothing extra needed here,
// but this is a good place for future initialisation work.
}
}
}

View File

@@ -0,0 +1,102 @@
package com.notificationmonitor.service;
import android.app.Notification;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.service.notification.NotificationListenerService;
import android.service.notification.StatusBarNotification;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import com.notificationmonitor.data.NotificationEntry;
import com.notificationmonitor.data.PreferencesManager;
/**
* Listens for notifications system-wide.
* Only processes packages the user has explicitly selected.
*
* NOTE: This service is enabled/disabled by the system once the user
* grants "Notification access" in Settings → Apps → Special app access.
*/
public class NotificationMonitorService extends NotificationListenerService {
public static final String ACTION_NOTIFICATION_RECEIVED =
"com.notificationmonitor.NOTIFICATION_RECEIVED";
public static final String EXTRA_ENTRY = "entry_json";
// Broadcast keys for the UI
public static final String EXTRA_APP_NAME = "app_name";
public static final String EXTRA_PKG = "pkg";
public static final String EXTRA_TITLE = "title";
public static final String EXTRA_TEXT = "text";
public static final String EXTRA_TIMESTAMP = "timestamp";
private PreferencesManager prefsManager;
@Override
public void onCreate() {
super.onCreate();
prefsManager = new PreferencesManager(this);
}
@Override
public void onNotificationPosted(StatusBarNotification sbn) {
if (sbn == null) return;
String pkg = sbn.getPackageName();
// Skip our own notifications and only process selected packages
if (pkg.equals(getPackageName())) return;
if (!prefsManager.isMonitored(pkg)) return;
Notification notification = sbn.getNotification();
if (notification == null) return;
Bundle extras = notification.extras;
String title = extras != null ? charSeqToString(extras.getCharSequence(Notification.EXTRA_TITLE)) : "";
String text = extras != null ? charSeqToString(extras.getCharSequence(Notification.EXTRA_TEXT)) : "";
if (text.isEmpty() && extras != null) {
// Fall back to big text
text = charSeqToString(extras.getCharSequence(Notification.EXTRA_BIG_TEXT));
}
String appName = getAppName(pkg);
long ts = System.currentTimeMillis();
// Persist to log
NotificationEntry entry = new NotificationEntry(appName, pkg, title, text, ts);
prefsManager.addNotificationEntry(entry);
// Broadcast to any open Activity
Intent broadcast = new Intent(ACTION_NOTIFICATION_RECEIVED);
broadcast.putExtra(EXTRA_APP_NAME, appName);
broadcast.putExtra(EXTRA_PKG, pkg);
broadcast.putExtra(EXTRA_TITLE, title);
broadcast.putExtra(EXTRA_TEXT, text);
broadcast.putExtra(EXTRA_TIMESTAMP, ts);
LocalBroadcastManager.getInstance(this).sendBroadcast(broadcast);
}
@Override
public void onNotificationRemoved(StatusBarNotification sbn) {
// No action needed for monitoring purposes
}
// ── Helpers ───────────────────────────────────────────────────────────
private String getAppName(String packageName) {
try {
PackageManager pm = getPackageManager();
ApplicationInfo info = pm.getApplicationInfo(packageName, 0);
return pm.getApplicationLabel(info).toString();
} catch (PackageManager.NameNotFoundException e) {
return packageName;
}
}
private String charSeqToString(CharSequence cs) {
return cs != null ? cs.toString() : "";
}
}

View File

@@ -0,0 +1,117 @@
package com.notificationmonitor.ui;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.MenuItem;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.notificationmonitor.data.AppInfo;
import com.notificationmonitor.data.PreferencesManager;
import com.notificationmonitor.databinding.ActivityAppSelectorBinding;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
public class AppSelectorActivity extends AppCompatActivity
implements AppSelectorAdapter.OnAppToggleListener {
private ActivityAppSelectorBinding binding;
private AppSelectorAdapter adapter;
private PreferencesManager prefsManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityAppSelectorBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbar);
if (getSupportActionBar() != null) {
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
}
prefsManager = new PreferencesManager(this);
adapter = new AppSelectorAdapter(this);
binding.recyclerApps.setLayoutManager(new LinearLayoutManager(this));
binding.recyclerApps.setAdapter(adapter);
// Search filter
binding.etSearch.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int st, int c, int a) {}
@Override public void onTextChanged(CharSequence s, int st, int b, int c) {
adapter.filter(s.toString());
}
@Override public void afterTextChanged(Editable s) {}
});
loadApps();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public void onAppToggled(String packageName, boolean selected) {
Set<String> monitored = prefsManager.getMonitoredPackages();
if (selected) {
monitored.add(packageName);
} else {
monitored.remove(packageName);
}
prefsManager.saveMonitoredPackages(monitored);
}
@SuppressWarnings("deprecation")
private void loadApps() {
binding.progressBar.setVisibility(android.view.View.VISIBLE);
AsyncTask.execute(() -> {
PackageManager pm = getPackageManager();
Set<String> monitored = prefsManager.getMonitoredPackages();
// Use MATCH_ALL to get every installed package regardless of visibility filters
List<ApplicationInfo> installedApps =
pm.getInstalledApplications(PackageManager.GET_META_DATA | PackageManager.MATCH_ALL);
List<AppInfo> appList = new ArrayList<>();
for (ApplicationInfo info : installedApps) {
// Skip our own app
if (info.packageName.equals(getPackageName())) continue;
// Include user-installed apps AND system apps that have a UI
boolean isUserApp = (info.flags & ApplicationInfo.FLAG_SYSTEM) == 0;
boolean isSystemWithUI = ((info.flags & ApplicationInfo.FLAG_SYSTEM) != 0)
&& pm.getLaunchIntentForPackage(info.packageName) != null;
if (isUserApp || isSystemWithUI) {
String name = pm.getApplicationLabel(info).toString();
boolean checked = monitored.contains(info.packageName);
appList.add(new AppInfo(name, info.packageName,
pm.getApplicationIcon(info), checked));
}
}
Collections.sort(appList,
(a, b) -> a.getAppName().compareToIgnoreCase(b.getAppName()));
runOnUiThread(() -> {
binding.progressBar.setVisibility(android.view.View.GONE);
adapter.setApps(appList);
});
});
}
}

View File

@@ -0,0 +1,97 @@
package com.notificationmonitor.ui;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.notificationmonitor.R;
import com.notificationmonitor.data.AppInfo;
import java.util.ArrayList;
import java.util.List;
public class AppSelectorAdapter
extends RecyclerView.Adapter<AppSelectorAdapter.ViewHolder> {
public interface OnAppToggleListener {
void onAppToggled(String packageName, boolean selected);
}
private List<AppInfo> allApps = new ArrayList<>();
private List<AppInfo> filteredApps = new ArrayList<>();
private final OnAppToggleListener listener;
public AppSelectorAdapter(OnAppToggleListener listener) {
this.listener = listener;
}
public void setApps(List<AppInfo> apps) {
allApps = new ArrayList<>(apps);
filteredApps = new ArrayList<>(apps);
notifyDataSetChanged();
}
public void filter(String query) {
filteredApps.clear();
if (query.isEmpty()) {
filteredApps.addAll(allApps);
} else {
String lq = query.toLowerCase();
for (AppInfo a : allApps) {
if (a.getAppName().toLowerCase().contains(lq) ||
a.getPackageName().toLowerCase().contains(lq)) {
filteredApps.add(a);
}
}
}
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_app, parent, false);
return new ViewHolder(v);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
AppInfo app = filteredApps.get(position);
holder.ivIcon.setImageDrawable(app.getIcon());
holder.tvName.setText(app.getAppName());
holder.tvPkg.setText(app.getPackageName());
// Prevent recycled checkbox from triggering listener
holder.checkBox.setOnCheckedChangeListener(null);
holder.checkBox.setChecked(app.isSelected());
holder.checkBox.setOnCheckedChangeListener((btn, checked) -> {
app.setSelected(checked);
listener.onAppToggled(app.getPackageName(), checked);
});
holder.itemView.setOnClickListener(v -> holder.checkBox.toggle());
}
@Override
public int getItemCount() { return filteredApps.size(); }
static class ViewHolder extends RecyclerView.ViewHolder {
ImageView ivIcon;
TextView tvName, tvPkg;
CheckBox checkBox;
ViewHolder(@NonNull View itemView) {
super(itemView);
ivIcon = itemView.findViewById(R.id.iv_app_icon);
tvName = itemView.findViewById(R.id.tv_app_name);
tvPkg = itemView.findViewById(R.id.tv_package_name);
checkBox = itemView.findViewById(R.id.checkbox_monitor);
}
}
}

View File

@@ -0,0 +1,148 @@
package com.notificationmonitor.ui;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.Bundle;
import android.provider.Settings;
import android.view.Menu;
import android.view.MenuItem;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import com.notificationmonitor.R;
import com.notificationmonitor.data.NotificationEntry;
import com.notificationmonitor.data.PreferencesManager;
import com.notificationmonitor.databinding.ActivityMainBinding;
import com.notificationmonitor.service.NotificationMonitorService;
import java.util.List;
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
private NotificationLogAdapter adapter;
private PreferencesManager prefsManager;
// Receives live notification events from the service
private final BroadcastReceiver notificationReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String appName = intent.getStringExtra(NotificationMonitorService.EXTRA_APP_NAME);
String pkg = intent.getStringExtra(NotificationMonitorService.EXTRA_PKG);
String title = intent.getStringExtra(NotificationMonitorService.EXTRA_TITLE);
String text = intent.getStringExtra(NotificationMonitorService.EXTRA_TEXT);
long timestamp = intent.getLongExtra(NotificationMonitorService.EXTRA_TIMESTAMP, 0);
NotificationEntry entry = new NotificationEntry(appName, pkg, title, text, timestamp);
adapter.addEntry(entry);
binding.recyclerNotifications.scrollToPosition(0);
updateEmptyState();
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
setSupportActionBar(binding.toolbar);
prefsManager = new PreferencesManager(this);
// Set up RecyclerView
adapter = new NotificationLogAdapter();
binding.recyclerNotifications.setLayoutManager(new LinearLayoutManager(this));
binding.recyclerNotifications.setAdapter(adapter);
// Load persisted log
List<NotificationEntry> log = prefsManager.getNotificationLog();
adapter.setEntries(log);
updateEmptyState();
// Prompt for notification access if not yet granted
if (!isNotificationAccessGranted()) {
showPermissionDialog();
}
// Select apps FAB
binding.fabSelectApps.setOnClickListener(v ->
startActivity(new Intent(this, AppSelectorActivity.class)));
}
@Override
protected void onResume() {
super.onResume();
LocalBroadcastManager.getInstance(this).registerReceiver(
notificationReceiver,
new IntentFilter(NotificationMonitorService.ACTION_NOTIFICATION_RECEIVED));
// Refresh banner in case user just granted access
updatePermissionBanner();
}
@Override
protected void onPause() {
super.onPause();
LocalBroadcastManager.getInstance(this).unregisterReceiver(notificationReceiver);
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_main, menu);
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.action_clear_log) {
prefsManager.clearLog();
adapter.clearEntries();
updateEmptyState();
return true;
}
if (item.getItemId() == R.id.action_settings) {
startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS));
return true;
}
return super.onOptionsItemSelected(item);
}
// ── Helpers ───────────────────────────────────────────────────────────
private boolean isNotificationAccessGranted() {
String flat = Settings.Secure.getString(
getContentResolver(), "enabled_notification_listeners");
return flat != null && flat.contains(getPackageName());
}
private void updatePermissionBanner() {
if (isNotificationAccessGranted()) {
binding.bannerPermission.setVisibility(android.view.View.GONE);
} else {
binding.bannerPermission.setVisibility(android.view.View.VISIBLE);
binding.bannerPermission.setOnClickListener(v ->
startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)));
}
}
private void showPermissionDialog() {
new AlertDialog.Builder(this)
.setTitle(R.string.permission_dialog_title)
.setMessage(R.string.permission_dialog_message)
.setPositiveButton(R.string.go_to_settings, (d, w) ->
startActivity(new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)))
.setNegativeButton(android.R.string.cancel, null)
.show();
}
private void updateEmptyState() {
boolean empty = adapter.getItemCount() == 0;
binding.emptyState.setVisibility(empty ? android.view.View.VISIBLE : android.view.View.GONE);
binding.recyclerNotifications.setVisibility(empty ? android.view.View.GONE : android.view.View.VISIBLE);
}
}

View File

@@ -0,0 +1,75 @@
package com.notificationmonitor.ui;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.notificationmonitor.R;
import com.notificationmonitor.data.NotificationEntry;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
public class NotificationLogAdapter
extends RecyclerView.Adapter<NotificationLogAdapter.ViewHolder> {
private final List<NotificationEntry> entries = new ArrayList<>();
private final SimpleDateFormat sdf =
new SimpleDateFormat("dd MMM HH:mm:ss", Locale.getDefault());
public void setEntries(List<NotificationEntry> list) {
entries.clear();
entries.addAll(list);
notifyDataSetChanged();
}
public void addEntry(NotificationEntry entry) {
entries.add(0, entry);
notifyItemInserted(0);
}
public void clearEntries() {
entries.clear();
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_notification, parent, false);
return new ViewHolder(v);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
NotificationEntry entry = entries.get(position);
holder.tvAppName.setText(entry.getAppName());
holder.tvTitle.setText(entry.getTitle().isEmpty() ? "(no title)" : entry.getTitle());
holder.tvText.setText(entry.getText().isEmpty() ? "(no content)" : entry.getText());
holder.tvTime.setText(sdf.format(new Date(entry.getTimestamp())));
holder.tvTitle.setVisibility(entry.getTitle().isEmpty() ? View.GONE : View.VISIBLE);
}
@Override
public int getItemCount() { return entries.size(); }
static class ViewHolder extends RecyclerView.ViewHolder {
TextView tvAppName, tvTitle, tvText, tvTime;
ViewHolder(@NonNull View itemView) {
super(itemView);
tvAppName = itemView.findViewById(R.id.tv_app_name);
tvTitle = itemView.findViewById(R.id.tv_title);
tvText = itemView.findViewById(R.id.tv_text);
tvTime = itemView.findViewById(R.id.tv_time);
}
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/surface"
app:title="@string/select_apps"
app:titleTextColor="@color/on_surface" />
</com.google.android.material.appbar.AppBarLayout>
<!-- Search box -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="12dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/et_search"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search_apps"
android:imeOptions="actionSearch"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<ProgressBar
android:id="@+id/progress_bar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_apps"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical" />
</LinearLayout>

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:elevation="4dp">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/surface"
app:title="@string/app_name"
app:titleTextColor="@color/on_surface" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<!-- Permission warning banner -->
<TextView
android:id="@+id/banner_permission"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/warning"
android:drawablePadding="8dp"
android:gravity="center_vertical"
android:padding="12dp"
android:text="@string/permission_banner"
android:textColor="@color/on_warning"
android:textSize="13sp"
android:visibility="gone" />
<!-- Notification log -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_notifications"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingBottom="80dp"
android:scrollbars="vertical" />
<!-- Empty state -->
<LinearLayout
android:id="@+id/empty_state"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🔔"
android:textSize="64sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/empty_state_title"
android:textColor="@color/on_surface"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:text="@string/empty_state_subtitle"
android:textColor="@color/on_surface_secondary"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
<!-- FAB to select apps -->
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab_select_apps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:text="@string/select_apps"
app:icon="@android:drawable/ic_menu_manage"
app:backgroundTint="@color/primary"
app:iconTint="@color/on_primary" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<ImageView
android:id="@+id/iv_app_icon"
android:layout_width="42dp"
android:layout_height="42dp"
android:contentDescription="@string/app_icon_desc"
android:scaleType="fitCenter" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tv_app_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/on_surface"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_package_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/on_surface_secondary"
android:textSize="11sp" />
</LinearLayout>
<CheckBox
android:id="@+id/checkbox_monitor"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:clickable="false"
android:focusable="false" />
</LinearLayout>

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
app:cardBackgroundColor="@color/surface"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="14dp">
<!-- Header row: app name + time -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_app_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textColor="@color/primary"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/on_surface_secondary"
android:textSize="11sp" />
</LinearLayout>
<!-- Notification title -->
<TextView
android:id="@+id/tv_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/on_surface"
android:textSize="14sp"
android:textStyle="bold" />
<!-- Notification body -->
<TextView
android:id="@+id/tv_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxLines="3"
android:ellipsize="end"
android:textColor="@color/on_surface_secondary"
android:textSize="13sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_clear_log"
android:title="Clear Log"
app:showAsAction="never" />
<item
android:id="@+id/action_settings"
android:title="Notification Access Settings"
app:showAsAction="never" />
</menu>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_notification_bell"
android:inset="20%" />
</foreground>
</adaptive-icon>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground>
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/ic_notification_bell"
android:inset="20%" />
</foreground>
</adaptive-icon>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Brand -->
<color name="primary">#1976D2</color>
<color name="primary_dark">#1565C0</color>
<color name="on_primary">#FFFFFF</color>
<!-- Surfaces -->
<color name="background">#F0F4F8</color>
<color name="surface">#FFFFFF</color>
<color name="on_surface">#1A1A2E</color>
<color name="on_surface_secondary">#6B7280</color>
<!-- Warning banner -->
<color name="warning">#FFF3CD</color>
<color name="on_warning">#856404</color>
<!-- Material defaults -->
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Notification Monitor</string>
<string name="service_label">Notification Monitor Service</string>
<string name="select_apps">Select Apps</string>
<string name="search_apps">Search apps…</string>
<string name="app_icon_desc">App icon</string>
<string name="permission_banner">⚠️ Notification access not granted — tap to enable</string>
<string name="permission_dialog_title">Notification Access Required</string>
<string name="permission_dialog_message">This app needs notification access to monitor selected apps. Tap "Go to Settings", find "Notification Monitor", and enable it.</string>
<string name="go_to_settings">Go to Settings</string>
<string name="empty_state_title">No notifications yet</string>
<string name="empty_state_subtitle">Select apps to monitor using the button below,\nthen notifications will appear here.</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.NotificationMonitor" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryVariant">@color/primary_dark</item>
<item name="colorOnPrimary">@color/on_primary</item>
<item name="android:windowBackground">@color/background</item>
</style>
</resources>

3
build.gradle Normal file
View File

@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.5.2' apply false
}

8
gradle.properties Normal file
View File

@@ -0,0 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.java.home.auto=false
android.useAndroidX=true
android.enableJetifier=true
# Required for JVM 21 compatibility with Gradle
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.configuration-cache=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

23
settings.gradle Normal file
View File

@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "NotificationMonitor"
include ':app'

0
test Normal file
View File