commit f52121883f61f972758008a8b125e6cc8206e040 Author: tototomate123 Date: Tue Jul 7 23:45:47 2026 +0200 Initial Instagram DM blocker app diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6033bd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.gradle/ +.repo-git/ +.kotlin/ +.idea/ +app/build/ +build/ +local.properties +*.hprof +*.apk +*.aab +*.iml diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f424071 --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0b8c6ca --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/example/instanoreels/InstaNoReelsApp.kt b/app/src/main/java/com/example/instanoreels/InstaNoReelsApp.kt new file mode 100644 index 0000000..32a9fbd --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/InstaNoReelsApp.kt @@ -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, +) diff --git a/app/src/main/java/com/example/instanoreels/MainActivity.kt b/app/src/main/java/com/example/instanoreels/MainActivity.kt new file mode 100644 index 0000000..ceaaec0 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/MainActivity.kt @@ -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)) +} diff --git a/app/src/main/java/com/example/instanoreels/accessibility/AccessibilityNodeReader.kt b/app/src/main/java/com/example/instanoreels/accessibility/AccessibilityNodeReader.kt new file mode 100644 index 0000000..ead8b2d --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/accessibility/AccessibilityNodeReader.kt @@ -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() + val descriptions = mutableListOf() + val ids = mutableListOf() + val stack = ArrayDeque() + 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 +} diff --git a/app/src/main/java/com/example/instanoreels/accessibility/BlockOverlayController.kt b/app/src/main/java/com/example/instanoreels/accessibility/BlockOverlayController.kt new file mode 100644 index 0000000..68b11ef --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/accessibility/BlockOverlayController.kt @@ -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() + + 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) + } +} diff --git a/app/src/main/java/com/example/instanoreels/accessibility/InstagramBlockerAccessibilityService.kt b/app/src/main/java/com/example/instanoreels/accessibility/InstagramBlockerAccessibilityService.kt new file mode 100644 index 0000000..8d37656 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/accessibility/InstagramBlockerAccessibilityService.kt @@ -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, + ) +} diff --git a/app/src/main/java/com/example/instanoreels/accessibility/InstagramDmNavigator.kt b/app/src/main/java/com/example/instanoreels/accessibility/InstagramDmNavigator.kt new file mode 100644 index 0000000..40ffbf8 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/accessibility/InstagramDmNavigator.kt @@ -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() + 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 +} diff --git a/app/src/main/java/com/example/instanoreels/accessibility/InstagramScreenClassifier.kt b/app/src/main/java/com/example/instanoreels/accessibility/InstagramScreenClassifier.kt new file mode 100644 index 0000000..07495e7 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/accessibility/InstagramScreenClassifier.kt @@ -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): 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) +} diff --git a/app/src/main/java/com/example/instanoreels/accessibility/InstagramScreenSnapshot.kt b/app/src/main/java/com/example/instanoreels/accessibility/InstagramScreenSnapshot.kt new file mode 100644 index 0000000..c518da1 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/accessibility/InstagramScreenSnapshot.kt @@ -0,0 +1,11 @@ +package com.example.instanoreels.accessibility + +data class InstagramScreenSnapshot( + val texts: List, + val contentDescriptions: List, + val viewIds: List, +) { + val searchableText: String = (texts + contentDescriptions + viewIds) + .joinToString(separator = " ") + .lowercase() +} diff --git a/app/src/main/java/com/example/instanoreels/data/BlockerSettings.kt b/app/src/main/java/com/example/instanoreels/data/BlockerSettings.kt new file mode 100644 index 0000000..f651799 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/data/BlockerSettings.kt @@ -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", +) diff --git a/app/src/main/java/com/example/instanoreels/data/SettingsStore.kt b/app/src/main/java/com/example/instanoreels/data/SettingsStore.kt new file mode 100644 index 0000000..c1ac17e --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/data/SettingsStore.kt @@ -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 = 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 = 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, 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") + } +} diff --git a/app/src/main/java/com/example/instanoreels/model/BlockDecision.kt b/app/src/main/java/com/example/instanoreels/model/BlockDecision.kt new file mode 100644 index 0000000..ce498a4 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/model/BlockDecision.kt @@ -0,0 +1,7 @@ +package com.example.instanoreels.model + +data class BlockDecision( + val shouldBlock: Boolean, + val area: InstagramArea, + val reason: String, +) diff --git a/app/src/main/java/com/example/instanoreels/model/InstagramArea.kt b/app/src/main/java/com/example/instanoreels/model/InstagramArea.kt new file mode 100644 index 0000000..0aac9f0 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/model/InstagramArea.kt @@ -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, +} diff --git a/app/src/main/java/com/example/instanoreels/ui/main/MainScreen.kt b/app/src/main/java/com/example/instanoreels/ui/main/MainScreen.kt new file mode 100644 index 0000000..208f281 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/ui/main/MainScreen.kt @@ -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.", + ) + } +} diff --git a/app/src/main/java/com/example/instanoreels/ui/main/MainViewModel.kt b/app/src/main/java/com/example/instanoreels/ui/main/MainViewModel.kt new file mode 100644 index 0000000..f28f1cb --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/ui/main/MainViewModel.kt @@ -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 = _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) } + } + } +} diff --git a/app/src/main/java/com/example/instanoreels/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/instanoreels/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..37c8b62 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/ui/settings/SettingsScreen.kt @@ -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.", + ) + } +} diff --git a/app/src/main/java/com/example/instanoreels/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/example/instanoreels/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..90298f4 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/ui/settings/SettingsViewModel.kt @@ -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 = _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) } +} diff --git a/app/src/main/java/com/example/instanoreels/ui/stats/StatsScreen.kt b/app/src/main/java/com/example/instanoreels/ui/stats/StatsScreen.kt new file mode 100644 index 0000000..0a78dd9 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/ui/stats/StatsScreen.kt @@ -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.", + ) + } +} diff --git a/app/src/main/java/com/example/instanoreels/ui/stats/StatsViewModel.kt b/app/src/main/java/com/example/instanoreels/ui/stats/StatsViewModel.kt new file mode 100644 index 0000000..44a5f84 --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/ui/stats/StatsViewModel.kt @@ -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 = _uiState.asStateFlow() + + init { + viewModelScope.launch { + settingsStore.stats.collect { _uiState.value = StatsUiState(it) } + } + } + + fun resetStats() = viewModelScope.launch { + settingsStore.resetStats() + } +} diff --git a/app/src/main/java/com/example/instanoreels/ui/theme/Theme.kt b/app/src/main/java/com/example/instanoreels/ui/theme/Theme.kt new file mode 100644 index 0000000..fb0ac3f --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/ui/theme/Theme.kt @@ -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) +} diff --git a/app/src/main/java/com/example/instanoreels/util/ViewModelFactory.kt b/app/src/main/java/com/example/instanoreels/util/ViewModelFactory.kt new file mode 100644 index 0000000..98a8e8b --- /dev/null +++ b/app/src/main/java/com/example/instanoreels/util/ViewModelFactory.kt @@ -0,0 +1,9 @@ +package com.example.instanoreels.util + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +class ViewModelFactory(private val create: () -> VM) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = create() as T +} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..2b4a9a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..7b02fd5 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,3 @@ + + #165D5A + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..debac97 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + DMs Only + DMs Only Instagram Blocker + Blocks distracting Instagram areas locally using Accessibility events. It does not read credentials, use network APIs, or send analytics. + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..68b2d31 --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + diff --git a/app/src/main/res/xml/instagram_blocker_accessibility_service.xml b/app/src/main/res/xml/instagram_blocker_accessibility_service.xml new file mode 100644 index 0000000..0b4498e --- /dev/null +++ b/app/src/main/res/xml/instagram_blocker_accessibility_service.xml @@ -0,0 +1,11 @@ + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..4f6af74 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "9.2.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.10" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.2.10" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..537c720 --- /dev/null +++ b/gradlew @@ -0,0 +1,14 @@ +#!/usr/bin/env sh + +set -e + +DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +CACHED_GRADLE="$HOME/.gradle/wrapper/dists/gradle-9.4.1-bin/arn2x92ynaizyzdaamcbpbhtj/gradle-9.4.1/bin/gradle" + +if [ -x "$CACHED_GRADLE" ]; then + exec "$CACHED_GRADLE" "$@" +fi + +echo "Gradle 9.4.1 is not unpacked in the local wrapper cache." >&2 +echo "Install Gradle or run a standard Gradle wrapper bootstrap for this project." >&2 +exit 1 diff --git a/proguard-rules.pro b/proguard-rules.pro new file mode 100644 index 0000000..2edc2ee --- /dev/null +++ b/proguard-rules.pro @@ -0,0 +1 @@ +# Keep default project rules minimal. This app has no network API layer. diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..16a621d --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "InstaNoReels" +include(":app")