diff --git a/App.tsx b/App.tsx
index fa8f395..c2c4fa9 100644
--- a/App.tsx
+++ b/App.tsx
@@ -18,6 +18,8 @@ import {
const { LimitterModule, TimerEventModule } = NativeModules;
+import { startCategoryService } from './src/services/categoryService';
+
type AppInfo = {
name: string;
package: string;
@@ -45,6 +47,7 @@ function App(): React.JSX.Element {
useEffect(() => {
refreshPermissions();
syncActiveTimers();
+ startCategoryService();
const sub = AppState.addEventListener('change', next => {
if (next === 'active') {
refreshPermissions();
diff --git a/Limitter-app b/Limitter-app
new file mode 160000
index 0000000..a7c67fa
--- /dev/null
+++ b/Limitter-app
@@ -0,0 +1 @@
+Subproject commit a7c67fa4615e3bb76176e797714654b731c395a4
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index fe505d0..110c6dc 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -21,18 +21,16 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
+
-
-
-
-
-
+ android:name=".MainActivity"
+ android:exported="true"
+ android:launchMode="singleTask">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
"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
+ }
+
+ override fun onInterrupt() {
+ Log.d(TAG, "Service onInterrupt")
+ }
+}
diff --git a/android/app/src/main/java/com/appguard2/CategoryTrackerModule.kt b/android/app/src/main/java/com/appguard2/CategoryTrackerModule.kt
new file mode 100644
index 0000000..3c1f26f
--- /dev/null
+++ b/android/app/src/main/java/com/appguard2/CategoryTrackerModule.kt
@@ -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)
+ } else {
+ reactContext.registerReceiver(receiver, filter)
+ }
+ }
+
+ @ReactMethod
+ fun stopTracking() {
+ receiver?.let {
+ reactContext.unregisterReceiver(it)
+ receiver = null
+ }
+ }
+
+ @ReactMethod
+ fun triggerBlock(type: String) {
+ // 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()
+ }
+}
diff --git a/android/app/src/main/java/com/appguard2/LimitterPackage.kt b/android/app/src/main/java/com/appguard2/LimitterPackage.kt
index d0cf385..169778d 100644
--- a/android/app/src/main/java/com/appguard2/LimitterPackage.kt
+++ b/android/app/src/main/java/com/appguard2/LimitterPackage.kt
@@ -9,7 +9,8 @@ class LimitterPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List {
return listOf(
LimitterModule(reactContext),
- TimerEventModule(reactContext)
+ TimerEventModule(reactContext),
+ CategoryTrackerModule(reactContext)
)
}
diff --git a/android/app/src/main/java/com/appguard2/WebsiteBlockerOverlayActivity.kt b/android/app/src/main/java/com/appguard2/WebsiteBlockerOverlayActivity.kt
new file mode 100644
index 0000000..794ae15
--- /dev/null
+++ b/android/app/src/main/java/com/appguard2/WebsiteBlockerOverlayActivity.kt
@@ -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()
+ }
+}
diff --git a/android/app/src/main/java/com/appguard2/WebsiteBlockerService.kt b/android/app/src/main/java/com/appguard2/WebsiteBlockerService.kt
new file mode 100644
index 0000000..01599a5
--- /dev/null
+++ b/android/app/src/main/java/com/appguard2/WebsiteBlockerService.kt
@@ -0,0 +1,119 @@
+package com.appguard2
+
+import android.accessibilityservice.AccessibilityService
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityNodeInfo
+
+class WebsiteBlockerService : AccessibilityService() {
+
+ private val TAG = "WebsiteBlockerService"
+ private val PREFS_NAME = "WebsiteBlockerPrefs"
+ private val KEY_GOOGLE_BLOCKED = "google_blocked"
+ private val handler = Handler(Looper.getMainLooper())
+ private var isTimerRunning = false
+ private var lastDetectedUrl = ""
+ private var lastDetectionTime = 0L
+
+ override fun onAccessibilityEvent(event: AccessibilityEvent?) {
+ if (event == null) return
+
+ val packageName = event.packageName?.toString() ?: return
+ if (packageName != "com.android.chrome" && packageName != "com.microsoft.emmx") {
+ return
+ }
+
+ val rootNode = rootInActiveWindow ?: return
+ val url = findUrl(rootNode, packageName) ?: return
+
+ // Filter duplicates and rapid repeated detections
+ val now = System.currentTimeMillis()
+ if (url == lastDetectedUrl && now - lastDetectionTime < 1000) {
+ return
+ }
+
+ lastDetectedUrl = url
+ lastDetectionTime = now
+
+ Log.d(TAG, "Detected URL: $url")
+
+ if (url.contains("google.com")) {
+ handleGoogleDetected()
+ } else {
+ if (isTimerRunning) {
+ Log.d(TAG, "Navigated away from Google, cancelling timer")
+ handler.removeCallbacksAndMessages(null)
+ isTimerRunning = false
+ }
+ }
+ }
+
+ 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
+ }
+ }
+
+ // Fallback: search for nodes that look like a URL bar (less reliable)
+ 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
+ }
+
+ private fun handleGoogleDetected() {
+ val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ val isBlocked = prefs.getBoolean(KEY_GOOGLE_BLOCKED, false)
+
+ if (isBlocked) {
+ showOverlay()
+ } else {
+ if (!isTimerRunning) {
+ isTimerRunning = true
+ Log.d(TAG, "Google detected, starting 3s timer")
+ handler.postDelayed({
+ prefs.edit().putBoolean(KEY_GOOGLE_BLOCKED, true).apply()
+ showOverlay()
+ isTimerRunning = false
+ }, 3000)
+ }
+ }
+ }
+
+ private fun showOverlay() {
+ val intent = Intent(this, WebsiteBlockerOverlayActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ startActivity(intent)
+ }
+
+ override fun onInterrupt() {
+ Log.d(TAG, "Service interrupted")
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e78475d..1b40683 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,3 +1,5 @@
AppGuard2
+ Detects and blocks websites.
+
diff --git a/android/app/src/main/res/xml/category_tracker_config.xml b/android/app/src/main/res/xml/category_tracker_config.xml
new file mode 100644
index 0000000..79a87c1
--- /dev/null
+++ b/android/app/src/main/res/xml/category_tracker_config.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/android/app/src/main/res/xml/website_blocker_config.xml b/android/app/src/main/res/xml/website_blocker_config.xml
new file mode 100644
index 0000000..82dfb8f
--- /dev/null
+++ b/android/app/src/main/res/xml/website_blocker_config.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/src/services/categoryMap.js b/src/services/categoryMap.js
new file mode 100644
index 0000000..afc5866
--- /dev/null
+++ b/src/services/categoryMap.js
@@ -0,0 +1,11 @@
+export const categoryMap = {
+ "com.facebook.katana": "Social Media",
+ "x.com": "Social Media",
+ "com.instagram.android": "Social Media",
+ "instagram.com": "Social Media",
+ "com.twitter.android": "Social Media", // Twitter app
+ "twitter.com": "Social Media", // Twitter website
+ "com.youtube.android": "Social Media", // YouTube app
+ "youtube.com": "Social Media" // YouTube website
+};
+export const CATEGORY_LIMIT = 30;
\ No newline at end of file
diff --git a/src/services/categoryService.js b/src/services/categoryService.js
new file mode 100644
index 0000000..08a8c87
--- /dev/null
+++ b/src/services/categoryService.js
@@ -0,0 +1,75 @@
+import { NativeModules, NativeEventEmitter } from 'react-native';
+import { categoryMap } from './categoryMap';
+import { incrementCategoryTime, isCategoryBlocked } from './categoryTimer';
+
+const { CategoryTrackerModule } = NativeModules;
+const eventEmitter = new NativeEventEmitter(CategoryTrackerModule);
+
+let currentItem = null;
+let timerInterval = null;
+
+export const startCategoryService = () => {
+ if (!CategoryTrackerModule) {
+ console.error("CategoryTrackerModule not found. Ensure Native code is integrated.");
+ return;
+ }
+
+ CategoryTrackerModule.startTracking();
+
+ eventEmitter.addListener('onForegroundChange', (event) => {
+ const { packageName, url } = event;
+
+ let detectedItem = null;
+ if (url && (packageName === 'com.android.chrome' || packageName === 'com.microsoft.emmx')) {
+ for (const key in categoryMap) {
+ if (url.includes(key)) {
+ detectedItem = key;
+ break;
+ }
+ }
+ }
+
+ if (!detectedItem && categoryMap[packageName]) {
+ detectedItem = packageName;
+ }
+
+ if (detectedItem !== currentItem) {
+ currentItem = detectedItem;
+ handleTimerUpdate();
+ }
+ });
+
+ console.log("[CategoryTracker] Service Initialized");
+};
+
+const handleTimerUpdate = () => {
+ if (timerInterval) {
+ clearInterval(timerInterval);
+ timerInterval = null;
+ }
+
+ const category = currentItem ? categoryMap[currentItem] : null;
+
+ if (category) {
+ console.log(`[CategoryTracker] Current foreground detected in category: ${category}`);
+
+ // If already blocked, trigger immediate overlay
+ if (isCategoryBlocked(category)) {
+ console.log(`[CategoryTracker] ${category} is already blocked! Triggering blocking overlay.`);
+ CategoryTrackerModule.triggerBlock(category);
+ return;
+ }
+
+ timerInterval = setInterval(() => {
+ const currentTime = incrementCategoryTime(category);
+ console.log(`[CategoryTracker] Category Total: ${currentTime}s`);
+
+ if (isCategoryBlocked(category)) {
+ console.log(`[CategoryTracker] Limit reached for ${category}!`);
+ CategoryTrackerModule.triggerBlock(category);
+ clearInterval(timerInterval);
+ timerInterval = null;
+ }
+ }, 1000);
+ }
+};
diff --git a/src/services/categoryTimer.js b/src/services/categoryTimer.js
new file mode 100644
index 0000000..c2cda51
--- /dev/null
+++ b/src/services/categoryTimer.js
@@ -0,0 +1,21 @@
+import { categoryMap, CATEGORY_LIMIT } from './categoryMap';
+
+let categoryTimeS = {
+ "Social Media": 0
+};
+
+export const incrementCategoryTime = (categoryName) => {
+ if (categoryTimeS[categoryName] !== undefined) {
+ categoryTimeS[categoryName] += 1;
+ return categoryTimeS[categoryName];
+ }
+ return 0;
+};
+
+export const isCategoryBlocked = (categoryName) => {
+ return (categoryTimeS[categoryName] || 0) >= CATEGORY_LIMIT;
+};
+
+export const getCategoryTime = (categoryName) => {
+ return categoryTimeS[categoryName] || 0;
+};