Initial Instagram DM blocker app

This commit is contained in:
2026-07-07 23:45:47 +02:00
commit f52121883f
33 changed files with 1448 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.gradle/
.repo-git/
.kotlin/
.idea/
app/build/
build/
local.properties
*.hprof
*.apk
*.aab
*.iml
+53
View File
@@ -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")
}
+31
View File
@@ -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)
}
}
@@ -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
}
@@ -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
}
+15
View File
@@ -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>
+3
View File
@@ -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