test
This commit is contained in:
0
.gitignore
vendored
Normal file
0
.gitignore
vendored
Normal file
86
README.md
Normal file
86
README.md
Normal 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
41
app/build.gradle
Normal 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
2
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
-keep class com.notificationmonitor.** { *; }
|
||||||
53
app/src/main/AndroidManifest.xml
Normal file
53
app/src/main/AndroidManifest.xml
Normal 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>
|
||||||
26
app/src/main/java/com/notificationmonitor/data/AppInfo.java
Normal file
26
app/src/main/java/com/notificationmonitor/data/AppInfo.java
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
148
app/src/main/java/com/notificationmonitor/ui/MainActivity.java
Normal file
148
app/src/main/java/com/notificationmonitor/ui/MainActivity.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/ic_notification_bell.xml
Normal file
10
app/src/main/res/drawable/ic_notification_bell.xml
Normal 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>
|
||||||
53
app/src/main/res/layout/activity_app_selector.xml
Normal file
53
app/src/main/res/layout/activity_app_selector.xml
Normal 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>
|
||||||
106
app/src/main/res/layout/activity_main.xml
Normal file
106
app/src/main/res/layout/activity_main.xml
Normal 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>
|
||||||
50
app/src/main/res/layout/item_app.xml
Normal file
50
app/src/main/res/layout/item_app.xml
Normal 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>
|
||||||
64
app/src/main/res/layout/item_notification.xml
Normal file
64
app/src/main/res/layout/item_notification.xml
Normal 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>
|
||||||
15
app/src/main/res/menu/menu_main.xml
Normal file
15
app/src/main/res/menu/menu_main.xml
Normal 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>
|
||||||
10
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
10
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal 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>
|
||||||
10
app/src/main/res/mipmap-hdpi/ic_launcher_round.xml
Normal file
10
app/src/main/res/mipmap-hdpi/ic_launcher_round.xml
Normal 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>
|
||||||
26
app/src/main/res/values/colors.xml
Normal file
26
app/src/main/res/values/colors.xml
Normal 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>
|
||||||
14
app/src/main/res/values/strings.xml
Normal file
14
app/src/main/res/values/strings.xml
Normal 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>
|
||||||
9
app/src/main/res/values/themes.xml
Normal file
9
app/src/main/res/values/themes.xml
Normal 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
3
build.gradle
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.5.2' apply false
|
||||||
|
}
|
||||||
8
gradle.properties
Normal file
8
gradle.properties
Normal 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
|
||||||
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
23
settings.gradle
Normal 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'
|
||||||
Reference in New Issue
Block a user