expo-app-blocker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +354 -0
- package/android/build.gradle +49 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/expo/modules/appblocker/AppBlockerPrefs.kt +21 -0
- package/android/src/main/java/expo/modules/appblocker/AppBlockerService.kt +176 -0
- package/android/src/main/java/expo/modules/appblocker/BootReceiver.kt +19 -0
- package/android/src/main/java/expo/modules/appblocker/ExpoAppBlockerModule.kt +116 -0
- package/android/src/main/java/expo/modules/appblocker/OverlayManager.kt +145 -0
- package/app.plugin.js +1 -0
- package/expo-module.config.json +9 -0
- package/package.json +44 -0
- package/plugin/src/index.js +196 -0
- package/src/ExpoAppBlocker.types.ts +102 -0
- package/src/index.ts +225 -0
- package/targets/DeviceActivityMonitor/DeviceActivityMonitor.swift +150 -0
- package/targets/DeviceActivityMonitor/expo-target.config.js +17 -0
- package/targets/ShieldAction/ShieldActionExtension.swift +86 -0
- package/targets/ShieldAction/expo-target.config.js +17 -0
- package/targets/ShieldConfiguration/ShieldConfigurationExtension.swift +152 -0
- package/targets/ShieldConfiguration/expo-target.config.js +17 -0
- package/tsconfig.json +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 expo-app-blocker contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# expo-app-blocker
|
|
2
|
+
|
|
3
|
+
Cross-platform app blocking module for Expo. Block other apps and redirect users to your app.
|
|
4
|
+
|
|
5
|
+
**Android**: UsageStatsManager + Foreground Service + System Overlay
|
|
6
|
+
**iOS**: Screen Time API (FamilyControls + ManagedSettings + DeviceActivity)
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- Block specific apps from being used
|
|
11
|
+
- Detect when a blocked app is opened (Android: polling, iOS: system shield)
|
|
12
|
+
- Customizable iOS shield overlay (icon, title, subtitle, button text, colors)
|
|
13
|
+
- Temporary unlock with timer
|
|
14
|
+
- Auto-relock when unlock period expires (iOS DeviceActivityMonitor extension)
|
|
15
|
+
- Notification when blocked app is detected
|
|
16
|
+
- Persist blocked apps across app restarts
|
|
17
|
+
- Native view for rendering blocked app names/icons on iOS (Apple's opaque tokens)
|
|
18
|
+
- Automatic iOS extension target creation via `@bacons/apple-targets`
|
|
19
|
+
- Full Expo Config Plugin - no manual native setup required
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
### Apple Developer Portal (iOS)
|
|
24
|
+
|
|
25
|
+
1. Register **4 App IDs** with **Family Controls** and **App Groups** capabilities:
|
|
26
|
+
- `com.yourapp.id` (main app)
|
|
27
|
+
- `com.yourapp.id.DeviceActivityMonitor`
|
|
28
|
+
- `com.yourapp.id.ShieldAction`
|
|
29
|
+
- `com.yourapp.id.ShieldConfiguration`
|
|
30
|
+
|
|
31
|
+
2. Create an **App Group**: `group.com.yourapp.blocker` (or your chosen identifier)
|
|
32
|
+
|
|
33
|
+
3. Assign the App Group to all 4 App IDs
|
|
34
|
+
|
|
35
|
+
4. Request **Family Controls** capability approval (works in dev builds without approval)
|
|
36
|
+
|
|
37
|
+
### Android
|
|
38
|
+
|
|
39
|
+
No special setup required beyond what the config plugin handles automatically.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx expo install expo-app-blocker
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> `@bacons/apple-targets` is included as a dependency for automatic iOS extension target creation.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Add the plugin to your `app.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"expo": {
|
|
56
|
+
"scheme": "myapp",
|
|
57
|
+
"ios": {
|
|
58
|
+
"bundleIdentifier": "com.yourapp.id",
|
|
59
|
+
"appleTeamId": "YOUR_TEAM_ID"
|
|
60
|
+
},
|
|
61
|
+
"android": {
|
|
62
|
+
"package": "com.yourapp.id"
|
|
63
|
+
},
|
|
64
|
+
"plugins": [
|
|
65
|
+
["expo-app-blocker", {
|
|
66
|
+
"ios": {
|
|
67
|
+
"appGroup": "group.com.yourapp.blocker",
|
|
68
|
+
"shield": {
|
|
69
|
+
"title": "Hold on!",
|
|
70
|
+
"subtitle": "{appName} is blocked.",
|
|
71
|
+
"primaryButtonLabel": "Earn Free Time",
|
|
72
|
+
"secondaryButtonLabel": "Not now",
|
|
73
|
+
"primaryButtonColor": "#7cb518",
|
|
74
|
+
"icon": "./assets/shield-icon.png"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}]
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Plugin Options
|
|
84
|
+
|
|
85
|
+
| Option | Type | Default | Description |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| `ios.appGroup` | `string` | Required | App Group identifier for shared data |
|
|
88
|
+
| `ios.shield.title` | `string` | `"Hold on!"` | Shield overlay title |
|
|
89
|
+
| `ios.shield.subtitle` | `string` | `"{appName} is blocked."` | Shield subtitle. `{appName}` is replaced with the blocked app name |
|
|
90
|
+
| `ios.shield.primaryButtonLabel` | `string` | `"Earn Free Time"` | Primary button text |
|
|
91
|
+
| `ios.shield.secondaryButtonLabel` | `string\|null` | `"Not now"` | Secondary button text. Set to `null` to hide |
|
|
92
|
+
| `ios.shield.primaryButtonColor` | `string` | `"#7cb518"` | Primary button background color (hex) |
|
|
93
|
+
| `ios.shield.backgroundBlurStyle` | `string` | `"systemThickMaterial"` | iOS blur style |
|
|
94
|
+
| `ios.shield.icon` | `string` | SF Symbol | Path to custom shield icon PNG (relative to project root, e.g. `"./assets/shield-icon.png"`) |
|
|
95
|
+
| `android.notificationTitle` | `string` | `"App Blocked"` | Notification title |
|
|
96
|
+
| `android.notificationText` | `string` | `"{appName} is blocked."` | Notification text |
|
|
97
|
+
|
|
98
|
+
### EAS Build
|
|
99
|
+
|
|
100
|
+
For EAS Build, declare extensions for credential management:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"extra": {
|
|
105
|
+
"eas": {
|
|
106
|
+
"build": {
|
|
107
|
+
"experimental": {
|
|
108
|
+
"ios": {
|
|
109
|
+
"appExtensions": [
|
|
110
|
+
{
|
|
111
|
+
"targetName": "DeviceActivityMonitor",
|
|
112
|
+
"bundleIdentifier": "com.yourapp.id.DeviceActivityMonitor",
|
|
113
|
+
"entitlements": {
|
|
114
|
+
"com.apple.developer.family-controls": true,
|
|
115
|
+
"com.apple.security.application-groups": ["group.com.yourapp.blocker"]
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"targetName": "ShieldAction",
|
|
120
|
+
"bundleIdentifier": "com.yourapp.id.ShieldAction",
|
|
121
|
+
"entitlements": {
|
|
122
|
+
"com.apple.developer.family-controls": true,
|
|
123
|
+
"com.apple.security.application-groups": ["group.com.yourapp.blocker"]
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"targetName": "ShieldConfiguration",
|
|
128
|
+
"bundleIdentifier": "com.yourapp.id.ShieldConfiguration",
|
|
129
|
+
"entitlements": {
|
|
130
|
+
"com.apple.developer.family-controls": true,
|
|
131
|
+
"com.apple.security.application-groups": ["group.com.yourapp.blocker"]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Build
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# Generate native projects
|
|
147
|
+
npx expo prebuild --clean
|
|
148
|
+
|
|
149
|
+
# Run on Android
|
|
150
|
+
npx expo run:android
|
|
151
|
+
|
|
152
|
+
# Run on iOS (physical device required for Screen Time APIs)
|
|
153
|
+
npx expo run:ios --device
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## API Reference
|
|
157
|
+
|
|
158
|
+
### Permissions
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
import { getPermissionStatus, requestPermissions } from 'expo-app-blocker';
|
|
162
|
+
|
|
163
|
+
// Check current permission status
|
|
164
|
+
const status = await getPermissionStatus();
|
|
165
|
+
// Returns: { allGranted: boolean, details: AndroidPermissions | IOSPermissions }
|
|
166
|
+
|
|
167
|
+
// Request permissions (iOS: triggers Screen Time authorization)
|
|
168
|
+
const result = await requestPermissions();
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Android: Permission Settings
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { openOverlaySettings, openUsageStatsSettings } from 'expo-app-blocker';
|
|
175
|
+
|
|
176
|
+
// Open system settings for overlay permission
|
|
177
|
+
openOverlaySettings();
|
|
178
|
+
|
|
179
|
+
// Open system settings for usage access
|
|
180
|
+
openUsageStatsSettings();
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Android: App Blocking
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
import { setBlockedApps, getBlockedApps, getInstalledApps } from 'expo-app-blocker';
|
|
187
|
+
|
|
188
|
+
// Get list of installed apps
|
|
189
|
+
const apps = await getInstalledApps();
|
|
190
|
+
// Returns: [{ packageName: string, name: string }]
|
|
191
|
+
|
|
192
|
+
// Set which apps to block (by package name)
|
|
193
|
+
setBlockedApps(['com.instagram.android', 'com.google.android.youtube']);
|
|
194
|
+
|
|
195
|
+
// Get currently blocked apps
|
|
196
|
+
const blocked = getBlockedApps();
|
|
197
|
+
// Returns: ['com.instagram.android', 'com.google.android.youtube']
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Android: Monitoring Control
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
import { startMonitoring, stopMonitoring } from 'expo-app-blocker';
|
|
204
|
+
|
|
205
|
+
// Start the foreground service (auto-started on module init)
|
|
206
|
+
startMonitoring();
|
|
207
|
+
|
|
208
|
+
// Stop monitoring
|
|
209
|
+
stopMonitoring();
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### iOS: App Selection
|
|
213
|
+
|
|
214
|
+
```typescript
|
|
215
|
+
import { presentFamilyActivityPicker } from 'expo-app-blocker';
|
|
216
|
+
|
|
217
|
+
// Opens the iOS system app/category picker
|
|
218
|
+
const items = await presentFamilyActivityPicker();
|
|
219
|
+
// Returns: IOSBlockedItem[] - opaque tokens for selected apps/categories
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### iOS: Block Configuration
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import { setBlockConfiguration, getBlockConfiguration, clearAllBlocks } from 'expo-app-blocker';
|
|
226
|
+
|
|
227
|
+
// Apply blocks (shields appear on selected apps)
|
|
228
|
+
await setBlockConfiguration({
|
|
229
|
+
blockedItems: items, // from presentFamilyActivityPicker()
|
|
230
|
+
isActive: true,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Get current configuration
|
|
234
|
+
const config = getBlockConfiguration();
|
|
235
|
+
|
|
236
|
+
// Remove all blocks
|
|
237
|
+
clearAllBlocks();
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### iOS: Temporary Unlock
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import {
|
|
244
|
+
temporaryUnlock,
|
|
245
|
+
isTemporarilyUnlocked,
|
|
246
|
+
getRemainingUnlockTime,
|
|
247
|
+
relockApps,
|
|
248
|
+
} from 'expo-app-blocker';
|
|
249
|
+
|
|
250
|
+
// Unlock for N minutes (removes shields temporarily)
|
|
251
|
+
const result = await temporaryUnlock(15);
|
|
252
|
+
// Returns: { unlocked: boolean, expiresAt: number }
|
|
253
|
+
|
|
254
|
+
// Check if currently unlocked
|
|
255
|
+
const unlocked = isTemporarilyUnlocked();
|
|
256
|
+
|
|
257
|
+
// Get remaining seconds
|
|
258
|
+
const seconds = getRemainingUnlockTime();
|
|
259
|
+
|
|
260
|
+
// Re-lock immediately
|
|
261
|
+
await relockApps();
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### iOS: Shield Button Events
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import { addPendingUnlockListener, checkAndClearPendingUnlock } from 'expo-app-blocker';
|
|
268
|
+
|
|
269
|
+
// Check if user tapped shield button while app was closed
|
|
270
|
+
const hasPending = checkAndClearPendingUnlock();
|
|
271
|
+
|
|
272
|
+
// Listen for real-time shield button taps
|
|
273
|
+
const subscription = addPendingUnlockListener(() => {
|
|
274
|
+
// User tapped "Earn Free Time" on the shield
|
|
275
|
+
// Navigate to your unlock/quiz screen
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Clean up
|
|
279
|
+
subscription?.remove();
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### iOS: Native Blocked Apps List
|
|
283
|
+
|
|
284
|
+
Renders blocked app tokens with real names and icons using Apple's native Label view:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import { BlockedAppsNativeList } from 'expo-app-blocker';
|
|
288
|
+
|
|
289
|
+
// In your component
|
|
290
|
+
<BlockedAppsNativeList
|
|
291
|
+
items={blockedItems}
|
|
292
|
+
selectionData={selectionBase64}
|
|
293
|
+
style={{ minHeight: 200 }}
|
|
294
|
+
/>
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Platform Notes
|
|
298
|
+
|
|
299
|
+
### iOS Limitations
|
|
300
|
+
|
|
301
|
+
- **Physical device required** - Screen Time APIs don't work in the simulator
|
|
302
|
+
- **App tokens are opaque** - You cannot extract app names/bundle IDs from tokens. Use `BlockedAppsNativeList` to render them with Apple's native Label
|
|
303
|
+
- **FamilyActivityPicker is required** - No API to enumerate installed apps on iOS
|
|
304
|
+
- **Shield customization is limited** - Only icon, title, subtitle, button labels, and colors can be changed. No custom views, fonts, or animations
|
|
305
|
+
- **Cannot open apps from shield** - Use notifications as a workaround to redirect users to your app
|
|
306
|
+
|
|
307
|
+
### Android Limitations
|
|
308
|
+
|
|
309
|
+
- **~500ms detection delay** - The foreground polling interval means a blocked app is briefly visible before the overlay appears
|
|
310
|
+
- **Overlay permission requires manual grant** - Users must enable "Display over other apps" in system settings
|
|
311
|
+
- **Usage access permission requires manual grant** - Users must enable in system settings
|
|
312
|
+
- **OEM battery optimizations** - Some manufacturers (Xiaomi, Samsung, etc.) may kill the foreground service. Users may need to disable battery optimization for your app
|
|
313
|
+
|
|
314
|
+
### Android Permissions (auto-added by config plugin)
|
|
315
|
+
|
|
316
|
+
| Permission | Purpose |
|
|
317
|
+
|---|---|
|
|
318
|
+
| `SYSTEM_ALERT_WINDOW` | Display blocking overlay |
|
|
319
|
+
| `FOREGROUND_SERVICE` | Run monitoring service |
|
|
320
|
+
| `FOREGROUND_SERVICE_SPECIAL_USE` | Required for Android 14+ |
|
|
321
|
+
| `PACKAGE_USAGE_STATS` | Detect foreground app |
|
|
322
|
+
| `RECEIVE_BOOT_COMPLETED` | Auto-start service on boot |
|
|
323
|
+
| `POST_NOTIFICATIONS` | Show blocked app notifications |
|
|
324
|
+
|
|
325
|
+
## How It Works
|
|
326
|
+
|
|
327
|
+
### Android Flow
|
|
328
|
+
|
|
329
|
+
1. `ExpoAppBlockerModule` starts `AppBlockerService` as a foreground service
|
|
330
|
+
2. Service polls `UsageStatsManager` every 500ms to detect the foreground app
|
|
331
|
+
3. If the foreground app is in the blocked list:
|
|
332
|
+
- A full-screen overlay covers the screen
|
|
333
|
+
- A notification is sent with a deep link to your app
|
|
334
|
+
- Your app is brought to the foreground
|
|
335
|
+
4. Blocked apps are persisted in SharedPreferences
|
|
336
|
+
|
|
337
|
+
### iOS Flow
|
|
338
|
+
|
|
339
|
+
1. User authorizes Screen Time via `requestPermissions()`
|
|
340
|
+
2. User selects apps to block via `presentFamilyActivityPicker()`
|
|
341
|
+
3. `setBlockConfiguration()` applies shields via `ManagedSettingsStore`
|
|
342
|
+
4. When a blocked app is opened, iOS shows the shield overlay (customized via `ShieldConfigurationExtension`)
|
|
343
|
+
5. When the user taps the shield button, `ShieldActionExtension` sends a notification via Darwin notification center
|
|
344
|
+
6. Your app receives the event and can navigate to an unlock flow
|
|
345
|
+
7. `temporaryUnlock()` removes shields for a duration
|
|
346
|
+
8. `DeviceActivityMonitor` extension re-applies shields when the unlock period expires
|
|
347
|
+
|
|
348
|
+
## Contributing
|
|
349
|
+
|
|
350
|
+
Contributions are welcome! Please open an issue or PR.
|
|
351
|
+
|
|
352
|
+
## License
|
|
353
|
+
|
|
354
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
apply plugin: 'com.android.library'
|
|
2
|
+
apply plugin: 'kotlin-android'
|
|
3
|
+
|
|
4
|
+
group = 'expo.modules.appblocker'
|
|
5
|
+
version = '0.1.0'
|
|
6
|
+
|
|
7
|
+
buildscript {
|
|
8
|
+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
|
9
|
+
if (expoModulesCorePlugin.exists()) {
|
|
10
|
+
apply from: expoModulesCorePlugin
|
|
11
|
+
applyKotlinExpoModulesCorePlugin()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
repositories {
|
|
15
|
+
mavenCentral()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dependencies {
|
|
19
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20")
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
android {
|
|
24
|
+
namespace "expo.modules.appblocker"
|
|
25
|
+
|
|
26
|
+
compileSdkVersion safeExtGet("compileSdkVersion", 35)
|
|
27
|
+
|
|
28
|
+
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
|
|
29
|
+
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
|
|
30
|
+
compileOptions {
|
|
31
|
+
sourceCompatibility JavaVersion.VERSION_17
|
|
32
|
+
targetCompatibility JavaVersion.VERSION_17
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
kotlinOptions {
|
|
36
|
+
jvmTarget = JavaVersion.VERSION_17.majorVersion
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
defaultConfig {
|
|
41
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
42
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 35)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
dependencies {
|
|
47
|
+
implementation project(':expo-modules-core')
|
|
48
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.1.20"
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest/>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package expo.modules.appblocker
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
|
|
6
|
+
object AppBlockerPrefs {
|
|
7
|
+
const val PREFS_NAME = "expo_app_blocker_prefs"
|
|
8
|
+
const val KEY_BLOCKED_PACKAGES = "blocked_packages"
|
|
9
|
+
|
|
10
|
+
fun get(context: Context): SharedPreferences =
|
|
11
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
12
|
+
|
|
13
|
+
fun getBlockedPackages(context: Context): Set<String> =
|
|
14
|
+
get(context).getStringSet(KEY_BLOCKED_PACKAGES, emptySet()) ?: emptySet()
|
|
15
|
+
|
|
16
|
+
fun setBlockedPackages(context: Context, packages: Collection<String>) {
|
|
17
|
+
get(context).edit()
|
|
18
|
+
.putStringSet(KEY_BLOCKED_PACKAGES, packages.toSet())
|
|
19
|
+
.apply()
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
package expo.modules.appblocker
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.app.PendingIntent
|
|
7
|
+
import android.app.Service
|
|
8
|
+
import android.app.usage.UsageEvents
|
|
9
|
+
import android.app.usage.UsageStatsManager
|
|
10
|
+
import android.content.Context
|
|
11
|
+
import android.content.Intent
|
|
12
|
+
import android.net.Uri
|
|
13
|
+
import android.os.Build
|
|
14
|
+
import android.os.Handler
|
|
15
|
+
import android.os.IBinder
|
|
16
|
+
import android.os.Looper
|
|
17
|
+
import android.util.Log
|
|
18
|
+
import androidx.core.app.NotificationCompat
|
|
19
|
+
|
|
20
|
+
class AppBlockerService : Service() {
|
|
21
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
22
|
+
private var lastForegroundPackage: String? = null
|
|
23
|
+
private lateinit var overlayManager: OverlayManager
|
|
24
|
+
|
|
25
|
+
private val pollRunnable = object : Runnable {
|
|
26
|
+
override fun run() {
|
|
27
|
+
val foregroundPackage = getCurrentForegroundPackage()
|
|
28
|
+
if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
|
|
29
|
+
Log.d(TAG, "Foreground changed: $foregroundPackage")
|
|
30
|
+
lastForegroundPackage = foregroundPackage
|
|
31
|
+
handleForegroundChange(foregroundPackage)
|
|
32
|
+
}
|
|
33
|
+
handler.postDelayed(this, POLL_INTERVAL_MS)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override fun onBind(intent: Intent?): IBinder? = null
|
|
38
|
+
|
|
39
|
+
override fun onCreate() {
|
|
40
|
+
super.onCreate()
|
|
41
|
+
Log.d(TAG, "AppBlockerService onCreate")
|
|
42
|
+
overlayManager = OverlayManager(this)
|
|
43
|
+
createChannelsIfNeeded()
|
|
44
|
+
startForeground(NOTIFICATION_ID, buildNotification())
|
|
45
|
+
handler.post(pollRunnable)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private fun handleForegroundChange(foregroundPackage: String) {
|
|
49
|
+
val blocked = AppBlockerPrefs.getBlockedPackages(this)
|
|
50
|
+
if (foregroundPackage in blocked) {
|
|
51
|
+
Log.d(TAG, "Blocked app in foreground: $foregroundPackage")
|
|
52
|
+
overlayManager.show(foregroundPackage)
|
|
53
|
+
showBlockedNotification(foregroundPackage)
|
|
54
|
+
} else {
|
|
55
|
+
overlayManager.hide()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private fun showBlockedNotification(packageName: String) {
|
|
60
|
+
val appName = try {
|
|
61
|
+
val pm = this.packageManager
|
|
62
|
+
val appInfo = pm.getApplicationInfo(packageName, 0)
|
|
63
|
+
pm.getApplicationLabel(appInfo).toString()
|
|
64
|
+
} catch (e: Exception) {
|
|
65
|
+
packageName
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
val scheme = this.packageName.replace(".", "-")
|
|
69
|
+
val deepLinkIntent = Intent(
|
|
70
|
+
Intent.ACTION_VIEW,
|
|
71
|
+
Uri.parse("${scheme}://blocked?app=${Uri.encode(appName)}&package=${Uri.encode(packageName)}")
|
|
72
|
+
).apply {
|
|
73
|
+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
val pendingIntent = PendingIntent.getActivity(
|
|
77
|
+
this, 0, deepLinkIntent,
|
|
78
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
val notification = NotificationCompat.Builder(this, BLOCKED_CHANNEL_ID)
|
|
82
|
+
.setContentTitle("App Blocked")
|
|
83
|
+
.setContentText("$appName is blocked. Tap to earn free time!")
|
|
84
|
+
.setSmallIcon(applicationInfo.icon)
|
|
85
|
+
.setAutoCancel(true)
|
|
86
|
+
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
87
|
+
.setContentIntent(pendingIntent)
|
|
88
|
+
.build()
|
|
89
|
+
|
|
90
|
+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
91
|
+
manager.notify(BLOCKED_NOTIFICATION_ID, notification)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
95
|
+
Log.d(TAG, "AppBlockerService onStartCommand")
|
|
96
|
+
return START_STICKY
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override fun onDestroy() {
|
|
100
|
+
Log.d(TAG, "AppBlockerService onDestroy")
|
|
101
|
+
handler.removeCallbacks(pollRunnable)
|
|
102
|
+
overlayManager.hide()
|
|
103
|
+
super.onDestroy()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private fun getCurrentForegroundPackage(): String? {
|
|
107
|
+
val usageStatsManager =
|
|
108
|
+
getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
|
|
109
|
+
val endTime = System.currentTimeMillis()
|
|
110
|
+
val beginTime = endTime - LOOKBACK_WINDOW_MS
|
|
111
|
+
val events = usageStatsManager.queryEvents(beginTime, endTime)
|
|
112
|
+
val event = UsageEvents.Event()
|
|
113
|
+
var latestForeground: String? = null
|
|
114
|
+
while (events.hasNextEvent()) {
|
|
115
|
+
events.getNextEvent(event)
|
|
116
|
+
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
|
|
117
|
+
latestForeground = event.packageName
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return latestForeground
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private fun createChannelsIfNeeded() {
|
|
124
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
125
|
+
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
126
|
+
|
|
127
|
+
val serviceChannel = NotificationChannel(
|
|
128
|
+
CHANNEL_ID, "App Blocker", NotificationManager.IMPORTANCE_LOW
|
|
129
|
+
).apply {
|
|
130
|
+
description = "Keeps the app blocker running"
|
|
131
|
+
setShowBadge(false)
|
|
132
|
+
}
|
|
133
|
+
manager.createNotificationChannel(serviceChannel)
|
|
134
|
+
|
|
135
|
+
val blockedChannel = NotificationChannel(
|
|
136
|
+
BLOCKED_CHANNEL_ID, "Blocked App Alerts", NotificationManager.IMPORTANCE_HIGH
|
|
137
|
+
).apply {
|
|
138
|
+
description = "Notifications when a blocked app is detected"
|
|
139
|
+
}
|
|
140
|
+
manager.createNotificationChannel(blockedChannel)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fun buildNotification(): Notification =
|
|
145
|
+
NotificationCompat.Builder(this, CHANNEL_ID)
|
|
146
|
+
.setContentTitle("App Blocker")
|
|
147
|
+
.setContentText("Monitoring blocked apps")
|
|
148
|
+
.setSmallIcon(applicationInfo.icon)
|
|
149
|
+
.setOngoing(true)
|
|
150
|
+
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
151
|
+
.build()
|
|
152
|
+
|
|
153
|
+
companion object {
|
|
154
|
+
private const val TAG = "ExpoAppBlocker"
|
|
155
|
+
private const val CHANNEL_ID = "expo_app_blocker_channel"
|
|
156
|
+
private const val BLOCKED_CHANNEL_ID = "expo_app_blocker_blocked"
|
|
157
|
+
private const val NOTIFICATION_ID = 9001
|
|
158
|
+
private const val BLOCKED_NOTIFICATION_ID = 9002
|
|
159
|
+
private const val POLL_INTERVAL_MS = 500L
|
|
160
|
+
private const val LOOKBACK_WINDOW_MS = 10_000L
|
|
161
|
+
|
|
162
|
+
fun start(context: Context) {
|
|
163
|
+
val intent = Intent(context, AppBlockerService::class.java)
|
|
164
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
165
|
+
context.startForegroundService(intent)
|
|
166
|
+
} else {
|
|
167
|
+
context.startService(intent)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
fun stop(context: Context) {
|
|
172
|
+
val intent = Intent(context, AppBlockerService::class.java)
|
|
173
|
+
context.stopService(intent)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
package expo.modules.appblocker
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.util.Log
|
|
7
|
+
|
|
8
|
+
class BootReceiver : BroadcastReceiver() {
|
|
9
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
10
|
+
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
|
11
|
+
Log.d(TAG, "BootReceiver: BOOT_COMPLETED received, starting service")
|
|
12
|
+
AppBlockerService.start(context.applicationContext)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
companion object {
|
|
17
|
+
private const val TAG = "ExpoAppBlocker"
|
|
18
|
+
}
|
|
19
|
+
}
|