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