Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {

const { LimitterModule, TimerEventModule } = NativeModules;

import { startCategoryService } from './src/services/categoryService';

Comment on lines 18 to +22

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

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

This import is placed after executable code. While still top-level, many TS/ESLint configurations enforce imports at the top for consistency and to avoid tooling edge-cases. Move import { startCategoryService ... } up with the other imports.

Suggested change
const { LimitterModule, TimerEventModule } = NativeModules;
import { startCategoryService } from './src/services/categoryService';
import { startCategoryService } from './src/services/categoryService';
const { LimitterModule, TimerEventModule } = NativeModules;

Copilot uses AI. Check for mistakes.
type AppInfo = {
name: string;
package: string;
Expand Down Expand Up @@ -45,6 +47,7 @@ function App(): React.JSX.Element {
useEffect(() => {
refreshPermissions();
syncActiveTimers();
startCategoryService();
const sub = AppState.addEventListener('change', next => {
if (next === 'active') {
refreshPermissions();
Expand Down
1 change: 1 addition & 0 deletions Limitter-app
Submodule Limitter-app added at a7c67f
52 changes: 41 additions & 11 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,16 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">

<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
Comment on lines 25 to +33

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

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

The MainActivity declaration dropped several React Native template attributes (android:configChanges, android:windowSoftInputMode, and android:label). Removing these commonly causes unwanted Activity recreation on rotation/config changes and can regress keyboard/resizing behavior. Consider restoring the previous attributes unless there’s a specific reason to remove them.

Copilot uses AI. Check for mistakes.
<service
android:name=".LimitterService"
android:enabled="true"
Expand All @@ -41,6 +39,38 @@
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Digital wellbeing app usage limitation and interface blocking" />
</service>

<service
android:name=".WebsiteBlockerService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/website_blocker_config" />
</service>

<!-- Category Tracking POC Service (Integration step) -->
<service
android:name=".CategoryTrackerAccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
android:exported="false">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/category_tracker_config" />
</service>
<activity
android:name=".WebsiteBlockerOverlayActivity"
android:label="Blocked"
android:theme="@style/AppTheme"
android:excludeFromRecents="true"
android:exported="false" />

<receiver
android:name=".AlarmReceiver"
android:enabled="true"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.appguard2

import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import android.util.Log

class CategoryTrackerAccessibilityService : AccessibilityService() {
private val TAG = "CategoryTrackingJS"
private var lastPackage = ""
private var lastUrl: String? = null
private var lastDetectionTime = 0L

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event == null) return

val packageName = event.packageName?.toString() ?: return
var currentUrl: String? = null

// 1. Detect Browser URL (if it's a browser)
if (packageName == "com.android.chrome" || packageName == "com.microsoft.emmx") {
val rootNode = rootInActiveWindow
if (rootNode != null) {
currentUrl = findUrl(rootNode, packageName)
}
}

// 2. Filter duplicates and rapid detections
val now = System.currentTimeMillis()
if (packageName == lastPackage && currentUrl == lastUrl && now - lastDetectionTime < 1000) {
return
}

lastPackage = packageName
lastUrl = currentUrl
lastDetectionTime = now

// 3. Broadcast Change
broadcastChange(packageName, currentUrl)
}

private fun broadcastChange(packageId: String, url: String?) {
val intent = Intent("com.appguard2.CATEGORY_TRACKER_CHANGE").apply {
putExtra("package", packageId)
if (url != null) putExtra("url", url)
setPackage(packageName) // Send only to our app
}
sendBroadcast(intent)
}

private fun findUrl(rootNode: AccessibilityNodeInfo, packageName: String): String? {
val urlBarId = when (packageName) {
"com.android.chrome" -> "com.android.chrome:id/url_bar"
"com.microsoft.emmx" -> "com.microsoft.emmx:id/url_bar"
else -> null
}

if (urlBarId != null) {
val nodes = rootNode.findAccessibilityNodeInfosByViewId(urlBarId)
if (nodes != null && nodes.isNotEmpty()) {
val url = nodes[0].text?.toString()
nodes.forEach { it.recycle() }
return url
}
}
return findUrlByRecursion(rootNode)
}

private fun findUrlByRecursion(node: AccessibilityNodeInfo): String? {
if (node.className == "android.widget.EditText" || node.className == "android.view.View") {
val text = node.text?.toString()
if (text != null && (text.contains(".") || text.contains("http"))) {
return text
}
}
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val result = findUrlByRecursion(child)
if (result != null) return result
}
return null
Comment on lines +77 to +82

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

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

Same AccessibilityNodeInfo recycling issue as in WebsiteBlockerService: child nodes from getChild(i) should be recycled, and any root node obtained from rootInActiveWindow should also be recycled. Not doing so can increase memory pressure in frequently-called accessibility callbacks.

Copilot uses AI. Check for mistakes.
}

override fun onInterrupt() {
Log.d(TAG, "Service onInterrupt")
}
}
91 changes: 91 additions & 0 deletions android/app/src/main/java/com/appguard2/CategoryTrackerModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.appguard2

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.util.Log
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule

class CategoryTrackerModule(private val reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {

private val TAG = "CategoryTrackerModule"
private var receiver: BroadcastReceiver? = null

override fun getName(): String {
return "CategoryTrackerModule"
}

@ReactMethod
fun startTracking() {
if (receiver != null) return

receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != "com.appguard2.CATEGORY_TRACKER_CHANGE") return

val packageName = intent.getStringExtra("package") ?: ""
val url = intent.getStringExtra("url") ?: ""

val params = Arguments.createMap().apply {
putString("packageName", packageName)
putString("url", url)
}

try {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onForegroundChange", params)
} catch (e: Exception) {
Log.e(TAG, "emit error: ${e.message}")
}
}
}

val filter = IntentFilter("com.appguard2.CATEGORY_TRACKER_CHANGE")
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
reactContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

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

For Android 13+ you’re registering the dynamic receiver as RECEIVER_EXPORTED, which allows other apps to send matching broadcasts into your process. Since this drives foreground events that can lead to blocking UI, prefer Context.RECEIVER_NOT_EXPORTED (and keep setPackage(...) on the sender side) to limit broadcasts to your app.

Suggested change
reactContext.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
reactContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)

Copilot uses AI. Check for mistakes.
} else {
reactContext.registerReceiver(receiver, filter)
}
}

@ReactMethod
fun stopTracking() {
receiver?.let {
reactContext.unregisterReceiver(it)
receiver = null
}
}

@ReactMethod
fun triggerBlock(type: String) {

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

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

The type parameter is unused, which makes the API misleading for JS callers. Either remove it (if not needed) or use it (e.g., choose overlay text/behavior based on category), or rename to _type to clearly indicate it’s intentionally unused.

Suggested change
fun triggerBlock(type: String) {
fun triggerBlock(_type: String) {

Copilot uses AI. Check for mistakes.
// Run on UI thread
reactContext.currentActivity?.let { activity ->
activity.runOnUiThread {
val intent = Intent(reactContext, WebsiteBlockerOverlayActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
reactContext.startActivity(intent)
}
} ?: run {
// Fallback: start directly from context
val intent = Intent(reactContext, WebsiteBlockerOverlayActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
reactContext.startActivity(intent)
}

// Also force close the current app (go home)
val homeIntent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
reactContext.startActivity(homeIntent)
}

override fun onCatalystInstanceDestroy() {
stopTracking()
}
}
3 changes: 2 additions & 1 deletion android/app/src/main/java/com/appguard2/LimitterPackage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ class LimitterPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(
LimitterModule(reactContext),
TimerEventModule(reactContext)
TimerEventModule(reactContext),
CategoryTrackerModule(reactContext)
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.appguard2

import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.WindowManager
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity

class WebsiteBlockerOverlayActivity : AppCompatActivity() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Make it full screen
window.setFlags(
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_FULLSCREEN
)

val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setBackgroundColor(Color.parseColor("#0f0c29"))
gravity = Gravity.CENTER
setPadding(80, 80, 80, 80)
}

root.addView(TextView(this).apply {
text = "🚫"
textSize = 100f
gravity = Gravity.CENTER
setPadding(0, 0, 0, 40)
})

root.addView(TextView(this).apply {
text = "Website Blocked"
setTextColor(Color.parseColor("#ef4444"))
textSize = 36f
setTypeface(null, android.graphics.Typeface.BOLD)
gravity = Gravity.CENTER
setPadding(0, 0, 0, 20)
})

root.addView(TextView(this).apply {
text = "You have reached your limit"
setTextColor(Color.parseColor("#c4b5fd"))
textSize = 20f
gravity = Gravity.CENTER
setPadding(0, 0, 0, 80)
})

root.addView(Button(this).apply {
text = "🏠 Go Home"
setBackgroundColor(Color.parseColor("#7c3aed"))
setTextColor(Color.WHITE)
textSize = 18f
setPadding(80, 40, 80, 40)
setOnClickListener {
val homeIntent = android.content.Intent(android.content.Intent.ACTION_MAIN).apply {
addCategory(android.content.Intent.CATEGORY_HOME)
flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(homeIntent)
finish()
}
})

setContentView(root)
}

override fun onBackPressed() {
// Disable back button
// Don't call super.onBackPressed()
}
}
Loading
Loading