From 4b6385200fb662104d516d099b1b687af370b5a9 Mon Sep 17 00:00:00 2001 From: sipra Date: Sun, 8 Mar 2026 01:41:50 +0500 Subject: [PATCH] Fix AndroidManifest.xml parsing, implement debouncing logic in accessibility services, and update category map --- App.tsx | 3 + Limitter-app | 1 + android/app/src/main/AndroidManifest.xml | 52 ++++++-- .../CategoryTrackerAccessibilityService.kt | 88 +++++++++++++ .../com/appguard2/CategoryTrackerModule.kt | 91 ++++++++++++++ .../java/com/appguard2/LimitterPackage.kt | 3 +- .../WebsiteBlockerOverlayActivity.kt | 79 ++++++++++++ .../com/appguard2/WebsiteBlockerService.kt | 119 ++++++++++++++++++ android/app/src/main/res/values/strings.xml | 2 + .../main/res/xml/category_tracker_config.xml | 7 ++ .../main/res/xml/website_blocker_config.xml | 8 ++ src/services/categoryMap.js | 11 ++ src/services/categoryService.js | 75 +++++++++++ src/services/categoryTimer.js | 21 ++++ 14 files changed, 548 insertions(+), 12 deletions(-) create mode 160000 Limitter-app create mode 100644 android/app/src/main/java/com/appguard2/CategoryTrackerAccessibilityService.kt create mode 100644 android/app/src/main/java/com/appguard2/CategoryTrackerModule.kt create mode 100644 android/app/src/main/java/com/appguard2/WebsiteBlockerOverlayActivity.kt create mode 100644 android/app/src/main/java/com/appguard2/WebsiteBlockerService.kt create mode 100644 android/app/src/main/res/xml/category_tracker_config.xml create mode 100644 android/app/src/main/res/xml/website_blocker_config.xml create mode 100644 src/services/categoryMap.js create mode 100644 src/services/categoryService.js create mode 100644 src/services/categoryTimer.js 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; +};