Initial Instagram DM blocker app
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
.gradle/
|
||||
.repo-git/
|
||||
.kotlin/
|
||||
.idea/
|
||||
app/build/
|
||||
build/
|
||||
local.properties
|
||||
*.hprof
|
||||
*.apk
|
||||
*.aab
|
||||
*.iml
|
||||
@@ -0,0 +1,53 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.instanoreels"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.example.instanoreels"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
|
||||
buildConfigField("String", "INSTAGRAM_PACKAGE", "\"com.instagram.android\"")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.activity:activity-compose:1.10.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose-android:2.9.2")
|
||||
implementation("androidx.compose.ui:ui-android:1.8.3")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview-android:1.8.3")
|
||||
implementation("androidx.compose.material3:material3-android:1.3.1")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling-android:1.8.3")
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:name=".InstaNoReelsApp"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/AppTheme">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".accessibility.InstagramBlockerAccessibilityService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.accessibilityservice.AccessibilityService" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accessibilityservice"
|
||||
android:resource="@xml/instagram_blocker_accessibility_service" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.example.instanoreels
|
||||
|
||||
import android.app.Application
|
||||
import com.example.instanoreels.data.SettingsStore
|
||||
|
||||
class InstaNoReelsApp : Application() {
|
||||
lateinit var container: AppContainer
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
container = AppContainer(SettingsStore(this))
|
||||
}
|
||||
}
|
||||
|
||||
data class AppContainer(
|
||||
val settingsStore: SettingsStore,
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.example.instanoreels
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.example.instanoreels.ui.main.MainScreen
|
||||
import com.example.instanoreels.ui.main.MainViewModel
|
||||
import com.example.instanoreels.ui.settings.SettingsScreen
|
||||
import com.example.instanoreels.ui.settings.SettingsViewModel
|
||||
import com.example.instanoreels.ui.stats.StatsScreen
|
||||
import com.example.instanoreels.ui.stats.StatsViewModel
|
||||
import com.example.instanoreels.ui.theme.DmsOnlyTheme
|
||||
import com.example.instanoreels.util.ViewModelFactory
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
val container = (application as InstaNoReelsApp).container
|
||||
|
||||
setContent {
|
||||
DmsOnlyTheme {
|
||||
DmsOnlyApp(
|
||||
settingsViewModel = viewModel(factory = ViewModelFactory { SettingsViewModel(container.settingsStore) }),
|
||||
statsViewModel = viewModel(factory = ViewModelFactory { StatsViewModel(container.settingsStore) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
}
|
||||
}
|
||||
|
||||
private enum class AppTab(val label: String) {
|
||||
Main("Main"),
|
||||
Settings("Settings"),
|
||||
Stats("Stats"),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun DmsOnlyApp(
|
||||
settingsViewModel: SettingsViewModel,
|
||||
statsViewModel: StatsViewModel,
|
||||
mainViewModel: MainViewModel = viewModel(),
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(AppTab.Main) }
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = { TopAppBar(title = { Text("DMs Only") }) },
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
AppTab.entries.forEach { tab ->
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == tab,
|
||||
onClick = {
|
||||
selectedTab = tab
|
||||
if (tab == AppTab.Main) mainViewModel.refresh(context)
|
||||
},
|
||||
label = { Text(tab.label) },
|
||||
icon = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Surface(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (selectedTab) {
|
||||
AppTab.Main -> MainScreen(
|
||||
viewModel = mainViewModel,
|
||||
onOpenAccessibilitySettings = {
|
||||
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
|
||||
},
|
||||
onOpenInstagram = {
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(BuildConfig.INSTAGRAM_PACKAGE)
|
||||
if (intent == null) {
|
||||
Toast.makeText(context, "Instagram is not installed.", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
},
|
||||
)
|
||||
AppTab.Settings -> SettingsScreen(settingsViewModel)
|
||||
AppTab.Stats -> StatsScreen(statsViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScreenColumn(contentPadding: PaddingValues = PaddingValues(20.dp), content: @Composable ColumnScope.() -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionTitle(text: String) {
|
||||
Text(text, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingRow(title: String, description: String? = null, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
if (description != null) {
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InfoBlock(title: String, body: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(body, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DividerWithSpace() {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.instanoreels.accessibility
|
||||
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
object AccessibilityNodeReader {
|
||||
fun read(root: AccessibilityNodeInfo?): InstagramScreenSnapshot {
|
||||
if (root == null) return InstagramScreenSnapshot(emptyList(), emptyList(), emptyList())
|
||||
|
||||
val texts = mutableListOf<String>()
|
||||
val descriptions = mutableListOf<String>()
|
||||
val ids = mutableListOf<String>()
|
||||
val stack = ArrayDeque<AccessibilityNodeInfo>()
|
||||
stack.add(root)
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val node = stack.removeLast()
|
||||
if (node.isVisibleToUser) {
|
||||
node.text?.toString()?.takeIf { it.isNotBlank() }?.let(texts::add)
|
||||
node.contentDescription?.toString()?.takeIf { it.isNotBlank() }?.let(descriptions::add)
|
||||
node.viewIdResourceName?.takeIf { it.isNotBlank() }?.let(ids::add)
|
||||
}
|
||||
|
||||
for (index in 0 until node.childCount) {
|
||||
node.getChild(index)?.let { child ->
|
||||
stack.add(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return InstagramScreenSnapshot(
|
||||
texts = texts.take(MAX_CAPTURED_ITEMS),
|
||||
contentDescriptions = descriptions.take(MAX_CAPTURED_ITEMS),
|
||||
viewIds = ids.take(MAX_CAPTURED_ITEMS),
|
||||
)
|
||||
}
|
||||
|
||||
private const val MAX_CAPTURED_ITEMS = 200
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.example.instanoreels.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import com.example.instanoreels.data.SettingsStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BlockOverlayController(
|
||||
private val service: AccessibilityService,
|
||||
private val settingsStore: SettingsStore,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var fullOverlayView: View? = null
|
||||
private val bottomGuardViews = mutableListOf<View>()
|
||||
|
||||
fun showFullBlock(
|
||||
allowTemporaryDisable: Boolean,
|
||||
primaryButtonText: String = "Open DMs",
|
||||
onPrimaryAction: () -> Unit,
|
||||
) {
|
||||
hideBottomGuard()
|
||||
if (fullOverlayView != null) return
|
||||
|
||||
val container = LinearLayout(service).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
setPadding(48, 48, 48, 48)
|
||||
setBackgroundColor(0xF20B0F14.toInt())
|
||||
}
|
||||
|
||||
val title = TextView(service).apply {
|
||||
text = "DMs only. No doomscrolling."
|
||||
textSize = 24f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
container.addView(title)
|
||||
|
||||
val primary = Button(service).apply {
|
||||
text = primaryButtonText
|
||||
setOnClickListener {
|
||||
hideFullBlock()
|
||||
onPrimaryAction()
|
||||
}
|
||||
}
|
||||
container.addView(primary)
|
||||
|
||||
if (allowTemporaryDisable) {
|
||||
val disable = Button(service).apply {
|
||||
text = "Disable for 5 minutes"
|
||||
setOnClickListener {
|
||||
scope.launch {
|
||||
settingsStore.disableForFiveMinutes()
|
||||
hideAll()
|
||||
onPrimaryAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
container.addView(disable)
|
||||
}
|
||||
|
||||
val params = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.MATCH_PARENT,
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
)
|
||||
|
||||
windowManager.addView(container, params)
|
||||
fullOverlayView = container
|
||||
}
|
||||
|
||||
fun showBottomGuard(onBlockedSlotTapped: () -> Unit) {
|
||||
if (fullOverlayView != null || bottomGuardViews.isNotEmpty()) return
|
||||
|
||||
val screenWidth = service.resources.displayMetrics.widthPixels
|
||||
val height = (service.resources.displayMetrics.density * 104).toInt()
|
||||
val dmLeft = (screenWidth * 0.42f).toInt()
|
||||
val dmRight = (screenWidth * 0.63f).toInt()
|
||||
|
||||
addBottomGuardSegment(
|
||||
width = dmLeft,
|
||||
height = height,
|
||||
gravity = Gravity.BOTTOM or Gravity.START,
|
||||
onBlockedSlotTapped = onBlockedSlotTapped,
|
||||
)
|
||||
addBottomGuardSegment(
|
||||
width = screenWidth - dmRight,
|
||||
height = height,
|
||||
gravity = Gravity.BOTTOM or Gravity.END,
|
||||
onBlockedSlotTapped = onBlockedSlotTapped,
|
||||
)
|
||||
}
|
||||
|
||||
fun hideFullBlock() {
|
||||
fullOverlayView?.let(windowManager::removeView)
|
||||
fullOverlayView = null
|
||||
}
|
||||
|
||||
fun hideBottomGuard() {
|
||||
bottomGuardViews.forEach(windowManager::removeView)
|
||||
bottomGuardViews.clear()
|
||||
}
|
||||
|
||||
fun hideAll() {
|
||||
hideFullBlock()
|
||||
hideBottomGuard()
|
||||
}
|
||||
|
||||
private fun addBottomGuardSegment(
|
||||
width: Int,
|
||||
height: Int,
|
||||
gravity: Int,
|
||||
onBlockedSlotTapped: () -> Unit,
|
||||
) {
|
||||
if (width <= 0) return
|
||||
|
||||
val view = View(service).apply {
|
||||
setBackgroundColor(0x220B0F14)
|
||||
setOnClickListener { onBlockedSlotTapped() }
|
||||
}
|
||||
val params = WindowManager.LayoutParams(
|
||||
width,
|
||||
height,
|
||||
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
this.gravity = gravity
|
||||
}
|
||||
|
||||
windowManager.addView(view, params)
|
||||
bottomGuardViews.add(view)
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package com.example.instanoreels.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import com.example.instanoreels.BuildConfig
|
||||
import com.example.instanoreels.data.SettingsStore
|
||||
import com.example.instanoreels.model.InstagramArea
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class InstagramBlockerAccessibilityService : AccessibilityService() {
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
private lateinit var settingsStore: SettingsStore
|
||||
private lateinit var overlayController: BlockOverlayController
|
||||
private val classifier = InstagramScreenClassifier()
|
||||
|
||||
private var lastDmThreadAtMillis: Long = 0L
|
||||
private var reelAllowanceStartedAtMillis: Long? = null
|
||||
private var lastBlockedAtMillis: Long = 0L
|
||||
private var lastBackActionAtMillis: Long = 0L
|
||||
private var lastDmRedirectAttemptAtMillis: Long = 0L
|
||||
private var redirectableBlockedAreaFirstSeenAtMillis: Long = 0L
|
||||
private var inReelViewerFromDm: Boolean = false
|
||||
|
||||
override fun onServiceConnected() {
|
||||
settingsStore = SettingsStore(applicationContext)
|
||||
overlayController = BlockOverlayController(this, settingsStore, serviceScope)
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
val packageName = event?.packageName?.toString()
|
||||
if (packageName == packageNameSafe) return
|
||||
|
||||
if (packageName != BuildConfig.INSTAGRAM_PACKAGE) {
|
||||
if (::overlayController.isInitialized) overlayController.hideAll()
|
||||
return
|
||||
}
|
||||
|
||||
serviceScope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
if (settingsStore.currentTemporaryDisableUntil() > now) {
|
||||
overlayController.hideAll()
|
||||
return@launch
|
||||
}
|
||||
|
||||
val settings = settingsStore.currentSettings()
|
||||
val root = rootInActiveWindow
|
||||
val snapshot = AccessibilityNodeReader.read(root)
|
||||
val area = classifier.classify(snapshot)
|
||||
if (area == InstagramArea.DM_THREAD) {
|
||||
lastDmThreadAtMillis = now
|
||||
reelAllowanceStartedAtMillis = null
|
||||
}
|
||||
if (area == InstagramArea.REELS && reelAllowanceStartedAtMillis == null) {
|
||||
reelAllowanceStartedAtMillis = now
|
||||
if (now - lastDmThreadAtMillis <= DM_REEL_VIEWER_CONTEXT_MILLIS) {
|
||||
inReelViewerFromDm = true
|
||||
}
|
||||
}
|
||||
if (area != InstagramArea.REELS) {
|
||||
reelAllowanceStartedAtMillis = null
|
||||
if (area !in setOf(InstagramArea.DM_THREAD, InstagramArea.UNKNOWN)) {
|
||||
inReelViewerFromDm = false
|
||||
}
|
||||
}
|
||||
if (area.isRedirectableBlockedArea()) {
|
||||
if (redirectableBlockedAreaFirstSeenAtMillis == 0L) {
|
||||
redirectableBlockedAreaFirstSeenAtMillis = now
|
||||
}
|
||||
} else {
|
||||
redirectableBlockedAreaFirstSeenAtMillis = 0L
|
||||
}
|
||||
|
||||
val decision = classifier.decide(
|
||||
snapshot = snapshot,
|
||||
settings = settings,
|
||||
nowMillis = now,
|
||||
lastDmThreadAtMillis = lastDmThreadAtMillis,
|
||||
reelAllowanceStartedAtMillis = reelAllowanceStartedAtMillis,
|
||||
)
|
||||
|
||||
if (!decision.shouldBlock) {
|
||||
overlayController.hideFullBlock()
|
||||
updateBottomGuard(settings, area)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (shouldBackOutOfDmViewer(decision.area, snapshot)) {
|
||||
overlayController.hideAll()
|
||||
performBackWithCooldown(now)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (settings.allowDms) {
|
||||
overlayController.hideFullBlock()
|
||||
overlayController.hideBottomGuard()
|
||||
|
||||
val shouldTryRedirect = now - lastDmRedirectAttemptAtMillis > DM_REDIRECT_ATTEMPT_COOLDOWN_MILLIS
|
||||
if (shouldTryRedirect) {
|
||||
lastDmRedirectAttemptAtMillis = now
|
||||
if (InstagramDmNavigator.tryOpenDmInbox(root)) return@launch
|
||||
if (InstagramDmNavigator.tryTapBottomDmTab(this@InstagramBlockerAccessibilityService)) return@launch
|
||||
}
|
||||
|
||||
if (decision.area.isRedirectableBlockedArea()) {
|
||||
val inRedirectGraceWindow =
|
||||
now - redirectableBlockedAreaFirstSeenAtMillis <= REDIRECT_GRACE_MILLIS
|
||||
if (inRedirectGraceWindow) return@launch
|
||||
}
|
||||
|
||||
if (settings.blockInstagramBottomNav) {
|
||||
overlayController.showBottomGuard {
|
||||
overlayController.hideBottomGuard()
|
||||
InstagramDmNavigator.tryTapBottomDmTab(this@InstagramBlockerAccessibilityService)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid repeatedly incrementing stats for the same blocked surface while Instagram redraws.
|
||||
if (now - lastBlockedAtMillis > BLOCK_DEBOUNCE_MILLIS) {
|
||||
settingsStore.incrementBlocked(decision.area.name)
|
||||
lastBlockedAtMillis = now
|
||||
}
|
||||
|
||||
overlayController.hideBottomGuard()
|
||||
if (settings.showOverlayBeforeGoingBack) {
|
||||
overlayController.showFullBlock(
|
||||
allowTemporaryDisable = settings.allowTemporaryDisableButton,
|
||||
primaryButtonText = if (shouldBackOutOfDmViewer(decision.area, snapshot)) "Go back" else "Open DMs",
|
||||
onPrimaryAction = {
|
||||
if (shouldBackOutOfDmViewer(decision.area, snapshot)) {
|
||||
performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
} else {
|
||||
redirectToDms(rootInActiveWindow)
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
if (shouldBackOutOfDmViewer(decision.area, snapshot)) {
|
||||
performBackWithCooldown(now)
|
||||
} else {
|
||||
redirectToDms(rootInActiveWindow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onInterrupt() {
|
||||
overlayController.hideAll()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
overlayController.hideAll()
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun updateBottomGuard(settings: com.example.instanoreels.data.BlockerSettings, area: InstagramArea) {
|
||||
val shouldShow = settings.blockerEnabled &&
|
||||
settings.blockInstagramBottomNav &&
|
||||
area in setOf(
|
||||
InstagramArea.HOME_FEED,
|
||||
InstagramArea.EXPLORE,
|
||||
InstagramArea.SEARCH,
|
||||
InstagramArea.REELS,
|
||||
InstagramArea.PROFILE,
|
||||
)
|
||||
|
||||
if (shouldShow) {
|
||||
overlayController.showBottomGuard {
|
||||
overlayController.hideBottomGuard()
|
||||
InstagramDmNavigator.tryTapBottomDmTab(this@InstagramBlockerAccessibilityService)
|
||||
}
|
||||
} else {
|
||||
overlayController.hideBottomGuard()
|
||||
}
|
||||
}
|
||||
|
||||
private fun performBackWithCooldown(now: Long) {
|
||||
if (now - lastBackActionAtMillis < BACK_ACTION_COOLDOWN_MILLIS) return
|
||||
lastBackActionAtMillis = now
|
||||
performGlobalAction(GLOBAL_ACTION_BACK)
|
||||
}
|
||||
|
||||
private fun redirectToDms(root: android.view.accessibility.AccessibilityNodeInfo?) {
|
||||
if (InstagramDmNavigator.tryOpenDmInbox(root)) return
|
||||
InstagramDmNavigator.tryTapBottomDmTab(this)
|
||||
}
|
||||
|
||||
private fun shouldBackOutOfDmViewer(area: InstagramArea, snapshot: InstagramScreenSnapshot): Boolean {
|
||||
val text = snapshot.searchableText
|
||||
val looksLikeDmReelViewer = text.contains("reply to") ||
|
||||
text.contains("reply to yourself") ||
|
||||
text.contains("send to") ||
|
||||
text.contains("see more") ||
|
||||
text.contains("antworten") ||
|
||||
text.contains("senden an")
|
||||
|
||||
return area == InstagramArea.DM_THREAD ||
|
||||
area == InstagramArea.REELS && inReelViewerFromDm ||
|
||||
looksLikeDmReelViewer
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BLOCK_DEBOUNCE_MILLIS = 2_000L
|
||||
const val BACK_ACTION_COOLDOWN_MILLIS = 1_200L
|
||||
const val DM_REDIRECT_ATTEMPT_COOLDOWN_MILLIS = 800L
|
||||
const val REDIRECT_GRACE_MILLIS = 4_000L
|
||||
const val DM_REEL_VIEWER_CONTEXT_MILLIS = 60_000L
|
||||
}
|
||||
|
||||
private val packageNameSafe: String
|
||||
get() = applicationContext.packageName
|
||||
}
|
||||
|
||||
private fun InstagramArea.isRedirectableBlockedArea(): Boolean {
|
||||
return this in setOf(
|
||||
InstagramArea.HOME_FEED,
|
||||
InstagramArea.REELS,
|
||||
InstagramArea.EXPLORE,
|
||||
InstagramArea.SEARCH,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.example.instanoreels.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.GestureDescription
|
||||
import android.graphics.Path
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
object InstagramDmNavigator {
|
||||
private val dmLabels = listOf(
|
||||
"direct",
|
||||
"messages",
|
||||
"messenger",
|
||||
"inbox",
|
||||
"chats",
|
||||
"nachrichten",
|
||||
"postfach",
|
||||
)
|
||||
|
||||
fun tryOpenDmInbox(root: AccessibilityNodeInfo?): Boolean {
|
||||
if (root == null) return false
|
||||
|
||||
val stack = ArrayDeque<AccessibilityNodeInfo>()
|
||||
stack.add(root)
|
||||
|
||||
while (stack.isNotEmpty()) {
|
||||
val node = stack.removeLast()
|
||||
if (node.isVisibleToUser && node.matchesDmEntryPoint() && node.clickSelfOrParent()) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (index in 0 until node.childCount) {
|
||||
node.getChild(index)?.let(stack::add)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
fun tryTapBottomDmTab(service: AccessibilityService): Boolean {
|
||||
val metrics = service.resources.displayMetrics
|
||||
val x = metrics.widthPixels * 0.50f
|
||||
val y = metrics.heightPixels * 0.955f
|
||||
val path = Path().apply { moveTo(x, y) }
|
||||
val gesture = GestureDescription.Builder()
|
||||
.addStroke(GestureDescription.StrokeDescription(path, 0L, 80L))
|
||||
.build()
|
||||
|
||||
return service.dispatchGesture(gesture, null, null)
|
||||
}
|
||||
|
||||
private fun AccessibilityNodeInfo.matchesDmEntryPoint(): Boolean {
|
||||
val text = listOfNotNull(
|
||||
text?.toString(),
|
||||
contentDescription?.toString(),
|
||||
viewIdResourceName,
|
||||
).joinToString(" ").lowercase()
|
||||
|
||||
return dmLabels.any { label -> text.contains(label) }
|
||||
}
|
||||
|
||||
private fun AccessibilityNodeInfo.clickSelfOrParent(): Boolean {
|
||||
var current: AccessibilityNodeInfo? = this
|
||||
repeat(MAX_PARENT_DEPTH) {
|
||||
val node = current ?: return false
|
||||
if (node.isClickable && node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
|
||||
return true
|
||||
}
|
||||
current = node.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private const val MAX_PARENT_DEPTH = 4
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package com.example.instanoreels.accessibility
|
||||
|
||||
import com.example.instanoreels.data.BlockerSettings
|
||||
import com.example.instanoreels.model.BlockDecision
|
||||
import com.example.instanoreels.model.InstagramArea
|
||||
|
||||
class InstagramScreenClassifier {
|
||||
fun classify(snapshot: InstagramScreenSnapshot): InstagramArea {
|
||||
val text = snapshot.searchableText
|
||||
return when {
|
||||
text.looksLikeAuthScreen() -> InstagramArea.AUTH
|
||||
text.looksLikeDmInbox() -> InstagramArea.DM_INBOX
|
||||
text.looksLikeDmThread() -> InstagramArea.DM_THREAD
|
||||
text.containsAny(reelsKeywords) -> InstagramArea.REELS
|
||||
text.containsAny(exploreKeywords) -> InstagramArea.EXPLORE
|
||||
text.containsAny(searchKeywords) -> InstagramArea.SEARCH
|
||||
text.containsAny(settingsKeywords) -> InstagramArea.SETTINGS
|
||||
text.containsAny(profileKeywords) -> InstagramArea.PROFILE
|
||||
text.containsAny(homeKeywords) -> InstagramArea.HOME_FEED
|
||||
else -> InstagramArea.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
fun decide(
|
||||
snapshot: InstagramScreenSnapshot,
|
||||
settings: BlockerSettings,
|
||||
nowMillis: Long,
|
||||
lastDmThreadAtMillis: Long,
|
||||
reelAllowanceStartedAtMillis: Long?,
|
||||
): BlockDecision {
|
||||
val area = classify(snapshot)
|
||||
val text = snapshot.searchableText
|
||||
|
||||
if (!settings.blockerEnabled) return BlockDecision(false, area, "Blocker disabled")
|
||||
|
||||
if (area == InstagramArea.AUTH) {
|
||||
return BlockDecision(false, area, "Login or account recovery screen allowed")
|
||||
}
|
||||
|
||||
if (settings.blockSuggestedPosts && text.containsAny(suggestedKeywords)) {
|
||||
return BlockDecision(true, area, "Suggested or recommendation UI detected")
|
||||
}
|
||||
|
||||
if (area in setOf(InstagramArea.DM_INBOX, InstagramArea.DM_THREAD) && settings.allowDms) {
|
||||
return BlockDecision(false, area, "DM area allowed")
|
||||
}
|
||||
|
||||
if (area == InstagramArea.REELS && settings.blockReels) {
|
||||
val withinDmWindow = nowMillis - lastDmThreadAtMillis <= DM_REEL_ALLOWANCE_MILLIS
|
||||
val withinStartedWindow = reelAllowanceStartedAtMillis?.let {
|
||||
nowMillis - it <= DM_REEL_ALLOWANCE_MILLIS
|
||||
} ?: withinDmWindow
|
||||
val hasRecommendationUi = text.containsAny(reelRecommendationKeywords)
|
||||
val canAllowSingleDmReel =
|
||||
settings.allowReelOpenedFromDmFor10Seconds &&
|
||||
!settings.strictMode &&
|
||||
withinStartedWindow &&
|
||||
!hasRecommendationUi
|
||||
|
||||
return if (canAllowSingleDmReel) {
|
||||
BlockDecision(false, area, "Temporary DM Reel allowance")
|
||||
} else {
|
||||
BlockDecision(true, area, "Reels blocked")
|
||||
}
|
||||
}
|
||||
|
||||
if (area in setOf(InstagramArea.EXPLORE, InstagramArea.SEARCH) && settings.blockExploreSearch) {
|
||||
return BlockDecision(true, area, "Explore/Search blocked")
|
||||
}
|
||||
|
||||
if (area == InstagramArea.HOME_FEED && settings.blockHomeFeed) {
|
||||
return BlockDecision(true, area, "Home feed blocked")
|
||||
}
|
||||
|
||||
return BlockDecision(false, area, "No matching block rule")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DM_REEL_ALLOWANCE_MILLIS = 10_000L
|
||||
|
||||
val authPrimaryKeywords = listOf(
|
||||
"log in",
|
||||
"login",
|
||||
"sign in",
|
||||
"check your email",
|
||||
"confirm your account",
|
||||
"anmelden",
|
||||
"einloggen",
|
||||
)
|
||||
val authSecondaryKeywords = listOf(
|
||||
"enter code",
|
||||
"get a new code",
|
||||
"try another way",
|
||||
"password",
|
||||
"incorrect",
|
||||
"verification code",
|
||||
"security code",
|
||||
"code eingeben",
|
||||
"neuen code",
|
||||
"passwort",
|
||||
"bestätigungscode",
|
||||
"sicherheitscode",
|
||||
)
|
||||
val reelsKeywords = listOf("reels", "reel", "rollen")
|
||||
val exploreKeywords = listOf("explore", "entdecken")
|
||||
val searchKeywords = listOf("search", "suchen")
|
||||
val suggestedKeywords = listOf(
|
||||
"suggested",
|
||||
"suggested posts",
|
||||
"suggested reels",
|
||||
"suggested for you",
|
||||
"for you",
|
||||
"vorgeschlagen",
|
||||
"für dich",
|
||||
"fur dich",
|
||||
)
|
||||
val reelRecommendationKeywords = suggestedKeywords + listOf("more reels", "mehr reels")
|
||||
val homeKeywords = listOf("home", "startseite", "feed")
|
||||
val dmInboxKeywords = listOf("messages", "direct", "inbox", "chats", "nachrichten", "postfach")
|
||||
val dmInboxPreviewKeywords = listOf(
|
||||
"new messages",
|
||||
"new message",
|
||||
"sent ",
|
||||
"active ",
|
||||
"neue nachrichten",
|
||||
"neue nachricht",
|
||||
"gesendet",
|
||||
"aktiv",
|
||||
)
|
||||
val dmThreadKeywords = listOf(
|
||||
"send message",
|
||||
"message...",
|
||||
"write a message",
|
||||
"reply",
|
||||
"antworten",
|
||||
"nachricht schreiben",
|
||||
"senden",
|
||||
)
|
||||
val profileKeywords = listOf("profile", "profil")
|
||||
val settingsKeywords = listOf("settings", "einstellungen")
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.containsAny(keywords: List<String>): Boolean {
|
||||
return keywords.any { contains(it.lowercase()) }
|
||||
}
|
||||
|
||||
private fun String.looksLikeAuthScreen(): Boolean {
|
||||
val hasPrimary = containsAny(InstagramScreenClassifier.authPrimaryKeywords)
|
||||
val hasSecondary = containsAny(InstagramScreenClassifier.authSecondaryKeywords)
|
||||
val hasMultipleSecondarySignals =
|
||||
InstagramScreenClassifier.authSecondaryKeywords.count { contains(it.lowercase()) } >= 2
|
||||
|
||||
return hasPrimary && hasSecondary || hasMultipleSecondarySignals
|
||||
}
|
||||
|
||||
private fun String.looksLikeDmInbox(): Boolean {
|
||||
val hasDirectLabel = containsAny(InstagramScreenClassifier.dmInboxKeywords)
|
||||
val previewSignalCount = InstagramScreenClassifier.dmInboxPreviewKeywords.count { contains(it.lowercase()) }
|
||||
val hasInboxChrome = contains("edit") || contains("compose") || contains("requests") || contains("anfragen")
|
||||
|
||||
return hasDirectLabel || previewSignalCount >= 2 || previewSignalCount >= 1 && hasInboxChrome
|
||||
}
|
||||
|
||||
private fun String.looksLikeDmThread(): Boolean {
|
||||
return containsAny(InstagramScreenClassifier.dmThreadKeywords)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.example.instanoreels.accessibility
|
||||
|
||||
data class InstagramScreenSnapshot(
|
||||
val texts: List<String>,
|
||||
val contentDescriptions: List<String>,
|
||||
val viewIds: List<String>,
|
||||
) {
|
||||
val searchableText: String = (texts + contentDescriptions + viewIds)
|
||||
.joinToString(separator = " ")
|
||||
.lowercase()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.instanoreels.data
|
||||
|
||||
data class BlockerSettings(
|
||||
val blockerEnabled: Boolean = true,
|
||||
val allowDms: Boolean = true,
|
||||
val blockReels: Boolean = true,
|
||||
val blockExploreSearch: Boolean = true,
|
||||
val blockHomeFeed: Boolean = true,
|
||||
val blockSuggestedPosts: Boolean = true,
|
||||
val strictMode: Boolean = false,
|
||||
val blockInstagramBottomNav: Boolean = true,
|
||||
val showOverlayBeforeGoingBack: Boolean = true,
|
||||
val allowReelOpenedFromDmFor10Seconds: Boolean = true,
|
||||
val allowTemporaryDisableButton: Boolean = false,
|
||||
val debugMode: Boolean = false,
|
||||
)
|
||||
|
||||
data class BlockerStats(
|
||||
val blockedCount: Int = 0,
|
||||
val lastBlockedArea: String = "NONE",
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.example.instanoreels.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.IOException
|
||||
|
||||
private val Context.localDataStore by preferencesDataStore(name = "local_blocker_preferences")
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
val settings: Flow<BlockerSettings> = context.localDataStore.data
|
||||
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
|
||||
.map { prefs ->
|
||||
BlockerSettings(
|
||||
blockerEnabled = prefs[BLOCKER_ENABLED] ?: true,
|
||||
allowDms = prefs[ALLOW_DMS] ?: true,
|
||||
blockReels = prefs[BLOCK_REELS] ?: true,
|
||||
blockExploreSearch = prefs[BLOCK_EXPLORE_SEARCH] ?: true,
|
||||
blockHomeFeed = prefs[BLOCK_HOME_FEED] ?: true,
|
||||
blockSuggestedPosts = prefs[BLOCK_SUGGESTED] ?: true,
|
||||
strictMode = prefs[STRICT_MODE] ?: false,
|
||||
blockInstagramBottomNav = prefs[BLOCK_BOTTOM_NAV] ?: true,
|
||||
showOverlayBeforeGoingBack = prefs[SHOW_OVERLAY] ?: true,
|
||||
allowReelOpenedFromDmFor10Seconds = prefs[ALLOW_DM_REEL] ?: true,
|
||||
allowTemporaryDisableButton = prefs[ALLOW_TEMP_DISABLE] ?: false,
|
||||
debugMode = prefs[DEBUG_MODE] ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
val stats: Flow<BlockerStats> = context.localDataStore.data
|
||||
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
|
||||
.map { prefs ->
|
||||
BlockerStats(
|
||||
blockedCount = prefs[BLOCKED_COUNT] ?: 0,
|
||||
lastBlockedArea = prefs[LAST_BLOCKED_AREA] ?: "NONE",
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun currentSettings(): BlockerSettings = settings.first()
|
||||
suspend fun currentTemporaryDisableUntil(): Long = context.localDataStore.data.first()[TEMP_DISABLED_UNTIL] ?: 0L
|
||||
|
||||
suspend fun setBlockerEnabled(value: Boolean) = set(BLOCKER_ENABLED, value)
|
||||
suspend fun setAllowDms(value: Boolean) = set(ALLOW_DMS, value)
|
||||
suspend fun setBlockReels(value: Boolean) = set(BLOCK_REELS, value)
|
||||
suspend fun setBlockExploreSearch(value: Boolean) = set(BLOCK_EXPLORE_SEARCH, value)
|
||||
suspend fun setBlockHomeFeed(value: Boolean) = set(BLOCK_HOME_FEED, value)
|
||||
suspend fun setBlockSuggestedPosts(value: Boolean) = set(BLOCK_SUGGESTED, value)
|
||||
suspend fun setStrictMode(value: Boolean) = set(STRICT_MODE, value)
|
||||
suspend fun setBlockInstagramBottomNav(value: Boolean) = set(BLOCK_BOTTOM_NAV, value)
|
||||
suspend fun setShowOverlayBeforeGoingBack(value: Boolean) = set(SHOW_OVERLAY, value)
|
||||
suspend fun setAllowReelOpenedFromDmFor10Seconds(value: Boolean) = set(ALLOW_DM_REEL, value)
|
||||
suspend fun setAllowTemporaryDisableButton(value: Boolean) = set(ALLOW_TEMP_DISABLE, value)
|
||||
suspend fun setDebugMode(value: Boolean) = set(DEBUG_MODE, value)
|
||||
|
||||
suspend fun incrementBlocked(area: String) {
|
||||
context.localDataStore.edit { prefs ->
|
||||
prefs[BLOCKED_COUNT] = (prefs[BLOCKED_COUNT] ?: 0) + 1
|
||||
prefs[LAST_BLOCKED_AREA] = area
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetStats() {
|
||||
context.localDataStore.edit { prefs ->
|
||||
prefs[BLOCKED_COUNT] = 0
|
||||
prefs[LAST_BLOCKED_AREA] = "NONE"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disableForFiveMinutes() {
|
||||
context.localDataStore.edit { prefs ->
|
||||
prefs[TEMP_DISABLED_UNTIL] = System.currentTimeMillis() + 5 * 60_000L
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun set(key: androidx.datastore.preferences.core.Preferences.Key<Boolean>, value: Boolean) {
|
||||
context.localDataStore.edit { it[key] = value }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val BLOCKER_ENABLED = booleanPreferencesKey("blocker_enabled")
|
||||
val ALLOW_DMS = booleanPreferencesKey("allow_dms")
|
||||
val BLOCK_REELS = booleanPreferencesKey("block_reels")
|
||||
val BLOCK_EXPLORE_SEARCH = booleanPreferencesKey("block_explore_search")
|
||||
val BLOCK_HOME_FEED = booleanPreferencesKey("block_home_feed")
|
||||
val BLOCK_SUGGESTED = booleanPreferencesKey("block_suggested_posts")
|
||||
val STRICT_MODE = booleanPreferencesKey("strict_mode")
|
||||
val BLOCK_BOTTOM_NAV = booleanPreferencesKey("block_instagram_bottom_nav")
|
||||
val SHOW_OVERLAY = booleanPreferencesKey("show_overlay_before_back")
|
||||
val ALLOW_DM_REEL = booleanPreferencesKey("allow_dm_reel_for_10_seconds")
|
||||
val ALLOW_TEMP_DISABLE = booleanPreferencesKey("allow_temporary_disable_button")
|
||||
val DEBUG_MODE = booleanPreferencesKey("debug_mode")
|
||||
val BLOCKED_COUNT = intPreferencesKey("blocked_count")
|
||||
val LAST_BLOCKED_AREA = stringPreferencesKey("last_blocked_area")
|
||||
val TEMP_DISABLED_UNTIL = longPreferencesKey("temporary_disabled_until")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.instanoreels.model
|
||||
|
||||
data class BlockDecision(
|
||||
val shouldBlock: Boolean,
|
||||
val area: InstagramArea,
|
||||
val reason: String,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.instanoreels.model
|
||||
|
||||
enum class InstagramArea {
|
||||
AUTH,
|
||||
DM_INBOX,
|
||||
DM_THREAD,
|
||||
REELS,
|
||||
EXPLORE,
|
||||
SEARCH,
|
||||
HOME_FEED,
|
||||
PROFILE,
|
||||
SETTINGS,
|
||||
UNKNOWN,
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.example.instanoreels.ui.main
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.instanoreels.InfoBlock
|
||||
import com.example.instanoreels.ScreenColumn
|
||||
|
||||
@Composable
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel,
|
||||
onOpenAccessibilitySettings: () -> Unit,
|
||||
onOpenInstagram: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.refresh(context)
|
||||
onDispose {}
|
||||
}
|
||||
|
||||
ScreenColumn {
|
||||
Text("Instagram DMs, without the scroll traps", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text(
|
||||
if (uiState.accessibilityEnabled) "AccessibilityService enabled" else "AccessibilityService not enabled",
|
||||
color = if (uiState.accessibilityEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = onOpenAccessibilitySettings, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Open Accessibility Settings")
|
||||
}
|
||||
OutlinedButton(onClick = onOpenInstagram, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Open Instagram")
|
||||
}
|
||||
}
|
||||
|
||||
InfoBlock(
|
||||
title = "What this app does",
|
||||
body = "It watches only Instagram's visible accessibility tree, allows DMs, and blocks local UI areas that look like Reels, Explore/Search, Home feed, or suggested content. It does not log in, scrape, use private APIs, or send analytics.",
|
||||
)
|
||||
InfoBlock(
|
||||
title = "Privacy",
|
||||
body = "Settings and stats stay on this device. Stats store only a blocked count and the last blocked enum-like area. Message text is not saved.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.example.instanoreels.ui.main
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.provider.Settings
|
||||
import android.text.TextUtils
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.example.instanoreels.accessibility.InstagramBlockerAccessibilityService
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
data class MainUiState(val accessibilityEnabled: Boolean = false)
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(MainUiState())
|
||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun refresh(context: Context) {
|
||||
_uiState.value = MainUiState(accessibilityEnabled = isAccessibilityServiceEnabled(context))
|
||||
}
|
||||
|
||||
private fun isAccessibilityServiceEnabled(context: Context): Boolean {
|
||||
val expected = ComponentName(context, InstagramBlockerAccessibilityService::class.java).flattenToString()
|
||||
val enabled = Settings.Secure.getString(
|
||||
context.contentResolver,
|
||||
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
|
||||
) ?: return false
|
||||
return TextUtils.SimpleStringSplitter(':').run {
|
||||
setString(enabled)
|
||||
any { it.equals(expected, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.example.instanoreels.ui.settings
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import com.example.instanoreels.DividerWithSpace
|
||||
import com.example.instanoreels.InfoBlock
|
||||
import com.example.instanoreels.ScreenColumn
|
||||
import com.example.instanoreels.SectionTitle
|
||||
import com.example.instanoreels.SettingRow
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(viewModel: SettingsViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val settings = uiState.settings
|
||||
|
||||
ScreenColumn {
|
||||
SectionTitle("Blocking")
|
||||
SettingRow("Enable blocker", checked = settings.blockerEnabled, onCheckedChange = viewModel::setBlockerEnabled)
|
||||
SettingRow("Allow DMs", checked = settings.allowDms, onCheckedChange = viewModel::setAllowDms)
|
||||
SettingRow("Block Reels", checked = settings.blockReels, onCheckedChange = viewModel::setBlockReels)
|
||||
SettingRow("Block Explore/Search", checked = settings.blockExploreSearch, onCheckedChange = viewModel::setBlockExploreSearch)
|
||||
SettingRow("Block Home feed", checked = settings.blockHomeFeed, onCheckedChange = viewModel::setBlockHomeFeed)
|
||||
SettingRow("Block Suggested Posts", checked = settings.blockSuggestedPosts, onCheckedChange = viewModel::setBlockSuggestedPosts)
|
||||
SettingRow("Strict Mode", description = "Blocks all Reels, including Reels that appear after a DM thread.", checked = settings.strictMode, onCheckedChange = viewModel::setStrictMode)
|
||||
SettingRow("Block Instagram bottom bar", description = "Places a local touch guard over Instagram's bottom navigation outside DMs/login.", checked = settings.blockInstagramBottomNav, onCheckedChange = viewModel::setBlockInstagramBottomNav)
|
||||
SettingRow("Show overlay before going back", checked = settings.showOverlayBeforeGoingBack, onCheckedChange = viewModel::setShowOverlay)
|
||||
SettingRow("Allow Reel opened from DM for 10 seconds", description = "Heuristic and imperfect. Recommendation UI still gets blocked.", checked = settings.allowReelOpenedFromDmFor10Seconds, onCheckedChange = viewModel::setAllowDmReel)
|
||||
|
||||
DividerWithSpace()
|
||||
SectionTitle("Optional")
|
||||
SettingRow("Show temporary disable button", description = "Adds a local overlay button to disable blocking for 5 minutes.", checked = settings.allowTemporaryDisableButton, onCheckedChange = viewModel::setAllowTemporaryDisableButton)
|
||||
SettingRow("Debug mode", description = "Reserved for local-only troubleshooting. The service still does not persist message contents.", checked = settings.debugMode, onCheckedChange = viewModel::setDebugMode)
|
||||
|
||||
InfoBlock(
|
||||
title = "Overlay permission",
|
||||
body = "The blocking overlay uses Android's Accessibility overlay window from the enabled service. It does not need the general draw-over-other-apps permission.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.example.instanoreels.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.instanoreels.data.BlockerSettings
|
||||
import com.example.instanoreels.data.SettingsStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsUiState(val settings: BlockerSettings = BlockerSettings())
|
||||
|
||||
class SettingsViewModel(private val settingsStore: SettingsStore) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsStore.settings.collect { _uiState.value = SettingsUiState(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setBlockerEnabled(value: Boolean) = viewModelScope.launch { settingsStore.setBlockerEnabled(value) }
|
||||
fun setAllowDms(value: Boolean) = viewModelScope.launch { settingsStore.setAllowDms(value) }
|
||||
fun setBlockReels(value: Boolean) = viewModelScope.launch { settingsStore.setBlockReels(value) }
|
||||
fun setBlockExploreSearch(value: Boolean) = viewModelScope.launch { settingsStore.setBlockExploreSearch(value) }
|
||||
fun setBlockHomeFeed(value: Boolean) = viewModelScope.launch { settingsStore.setBlockHomeFeed(value) }
|
||||
fun setBlockSuggestedPosts(value: Boolean) = viewModelScope.launch { settingsStore.setBlockSuggestedPosts(value) }
|
||||
fun setStrictMode(value: Boolean) = viewModelScope.launch { settingsStore.setStrictMode(value) }
|
||||
fun setBlockInstagramBottomNav(value: Boolean) = viewModelScope.launch { settingsStore.setBlockInstagramBottomNav(value) }
|
||||
fun setShowOverlay(value: Boolean) = viewModelScope.launch { settingsStore.setShowOverlayBeforeGoingBack(value) }
|
||||
fun setAllowDmReel(value: Boolean) = viewModelScope.launch { settingsStore.setAllowReelOpenedFromDmFor10Seconds(value) }
|
||||
fun setAllowTemporaryDisableButton(value: Boolean) = viewModelScope.launch { settingsStore.setAllowTemporaryDisableButton(value) }
|
||||
fun setDebugMode(value: Boolean) = viewModelScope.launch { settingsStore.setDebugMode(value) }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.example.instanoreels.ui.stats
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.example.instanoreels.InfoBlock
|
||||
import com.example.instanoreels.ScreenColumn
|
||||
|
||||
@Composable
|
||||
fun StatsScreen(viewModel: StatsViewModel) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val stats = uiState.stats
|
||||
|
||||
ScreenColumn {
|
||||
Text("Local stats", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Blocked count: ${stats.blockedCount}", style = MaterialTheme.typography.titleLarge)
|
||||
Text("Last blocked area: ${stats.lastBlockedArea}", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
Button(onClick = viewModel::resetStats, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Reset stats")
|
||||
}
|
||||
InfoBlock(
|
||||
title = "Stored data",
|
||||
body = "Only the count and last blocked area are persisted. Instagram UI text is used transiently for classification and is not saved.",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.instanoreels.ui.stats
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.example.instanoreels.data.BlockerStats
|
||||
import com.example.instanoreels.data.SettingsStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class StatsUiState(val stats: BlockerStats = BlockerStats())
|
||||
|
||||
class StatsViewModel(private val settingsStore: SettingsStore) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(StatsUiState())
|
||||
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsStore.stats.collect { _uiState.value = StatsUiState(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun resetStats() = viewModelScope.launch {
|
||||
settingsStore.resetStats()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.example.instanoreels.ui.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val colors = lightColorScheme(
|
||||
primary = Color(0xFF165D5A),
|
||||
secondary = Color(0xFF4B635F),
|
||||
background = Color(0xFFF8FAF9),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onPrimary = Color.White,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DmsOnlyTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = colors, content = content)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.example.instanoreels.util
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
|
||||
class ViewModelFactory<VM : ViewModel>(private val create: () -> VM) : ViewModelProvider.Factory {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T = create() as T
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="#165D5A"
|
||||
android:pathData="M0,0h48v48h-48z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M10,14a6,6 0,0 1,6 -6h16a6,6 0,0 1,6 6v10a6,6 0,0 1,-6 6h-10l-8,8v-8a6,6 0,0 1,-4 -6z" />
|
||||
<path
|
||||
android:fillColor="#165D5A"
|
||||
android:pathData="M16,17h16v3h-16zM16,23h10v3h-10z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="launcher_background">#165D5A</color>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user