implemented daemon

This commit is contained in:
Acid
2026-03-22 21:02:51 -04:00
parent 66242f9443
commit 7cd8565235
3 changed files with 341 additions and 99 deletions
+17 -1
View File
@@ -1,8 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Required to query all installed apps on Android 11+ -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
@@ -11,6 +17,16 @@
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.Stims">
<service
android:name=".StimsService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Monitoring apps to keep screen on" />
</service>
<activity
android:name=".MainActivity"
android:exported="true">
@@ -1,9 +1,15 @@
package com.example.stims
import android.app.AppOpsManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Bundle
import android.os.Process
import android.provider.Settings
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -13,15 +19,18 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -33,33 +42,85 @@ data class AppInfo(
)
class MainActivity : ComponentActivity() {
private lateinit var prefs: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prefs = getSharedPreferences("stims_prefs", Context.MODE_PRIVATE)
setContent {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
AppListScreen()
AppListScreen(prefs)
}
}
}
override fun onResume() {
super.onResume()
if (!hasUsageStatsPermission(this)) {
Toast.makeText(this, "Please enable Usage Stats permission", Toast.LENGTH_LONG).show()
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
}
}
private fun hasUsageStatsPermission(context: Context): Boolean {
val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
}
return mode == AppOpsManager.MODE_ALLOWED
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppListScreen() {
fun AppListScreen(prefs: SharedPreferences) {
val context = LocalContext.current
val packageManager = context.packageManager
var allApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
// Persistent list of apps that are "STIMMED" (Stay Awake Enabled)
val stimmedPackages = remember {
mutableStateListOf<String>().apply {
addAll(prefs.getStringSet("stimmed_apps", emptySet()) ?: emptySet())
}
}
// Temporary selection in the "All Apps" list
val tempSelected = remember { mutableStateListOf<String>() }
// Automatic Daemon Lifecycle: Starts if list > 0, Stops if list == 0
LaunchedEffect(stimmedPackages.toList()) {
prefs.edit().putStringSet("stimmed_apps", stimmedPackages.toSet()).apply()
val intent = Intent(context, StimsService::class.java).apply {
putStringArrayListExtra("selected_packages", ArrayList(stimmedPackages))
}
if (stimmedPackages.isEmpty()) {
context.stopService(intent)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
}
LaunchedEffect(Unit) {
allApps = withContext(Dispatchers.IO) {
val intent = Intent(Intent.ACTION_MAIN, null).apply {
addCategory(Intent.CATEGORY_LAUNCHER)
}
// Query for all activities that can be launched
val resolveInfos = packageManager.queryIntentActivities(intent, 0)
resolveInfos.map { resolveInfo ->
@@ -68,80 +129,91 @@ fun AppListScreen() {
packageName = resolveInfo.activityInfo.packageName,
icon = resolveInfo.loadIcon(packageManager)
)
}.distinctBy { it.packageName } // Avoid duplicates if an app has multiple launchers
}.distinctBy { it.packageName }
.sortedBy { it.name }
}
isLoading = false
}
var searchQuery by remember { mutableStateOf("") }
val selectedApps = remember { mutableStateListOf<AppInfo>() }
val filteredApps = remember(searchQuery, allApps) {
if (searchQuery.isBlank()) {
allApps
} else {
allApps.filter {
it.name.contains(searchQuery, ignoreCase = true) ||
it.packageName.contains(searchQuery, ignoreCase = true)
}
val stimList = remember(stimmedPackages.toList(), allApps) {
allApps.filter { it.packageName in stimmedPackages }
}
val availableApps = remember(searchQuery, stimmedPackages.toList(), allApps) {
allApps.filter { it.packageName !in stimmedPackages &&
(searchQuery.isBlank() || it.name.contains(searchQuery, ignoreCase = true) || it.packageName.contains(searchQuery, ignoreCase = true))
}
}
if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
placeholder = { Text("Search apps...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true
Scaffold(
topBar = {
TopAppBar(
title = { Text("Stims Manager", fontWeight = FontWeight.Bold) }
)
if (selectedApps.isNotEmpty()) {
Text(
text = "${selectedApps.size} apps selected",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.primary
)
}
) { padding ->
if (isLoading) {
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
LazyColumn(modifier = Modifier.weight(1f)) {
items(filteredApps) { app ->
val isSelected = selectedApps.any { it.packageName == app.packageName }
AppItem(
app = app,
isSelected = isSelected,
onToggleSelection = {
if (isSelected) {
selectedApps.removeAll { it.packageName == app.packageName }
} else {
selectedApps.add(app)
}
}
)
}
}
if (selectedApps.isNotEmpty()) {
Button(
onClick = {
val names = selectedApps.joinToString { it.name }
Toast.makeText(context, "Selected: $names", Toast.LENGTH_LONG).show()
},
} else {
Column(modifier = Modifier.padding(padding)) {
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.padding(16.dp),
placeholder = { Text("Search apps to stim...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true
)
LazyColumn(modifier = Modifier.weight(1f)) {
if (stimList.isNotEmpty()) {
item {
SectionHeader("STAY AWAKE (STIMMED)")
}
items(stimList) { app ->
StimmedAppItem(app = app) {
stimmedPackages.remove(app.packageName)
}
}
}
item {
SectionHeader("ALL APPS")
}
items(availableApps) { app ->
SelectableAppItem(
app = app,
isSelected = app.packageName in tempSelected,
onToggle = {
if (app.packageName in tempSelected) tempSelected.remove(app.packageName)
else tempSelected.add(app.packageName)
}
)
}
}
Button(
onClick = {
if (tempSelected.isEmpty()) {
Toast.makeText(context, "Check some apps first!", Toast.LENGTH_SHORT).show()
} else {
stimmedPackages.addAll(tempSelected)
tempSelected.clear()
// Service lifecycle is handled automatically by LaunchedEffect
Toast.makeText(context, "Apps Stimmed!", Toast.LENGTH_SHORT).show()
}
},
modifier = Modifier.fillMaxWidth().padding(16.dp),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
shape = MaterialTheme.shapes.medium
) {
Text("Confirm Selection")
Text("STIM SELECTED APPS", fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
}
@@ -149,58 +221,81 @@ fun AppListScreen() {
}
@Composable
fun AppItem(
app: AppInfo,
isSelected: Boolean,
onToggleSelection: () -> Unit
) {
fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge.copy(
fontWeight = FontWeight.ExtraBold,
color = MaterialTheme.colorScheme.secondary,
letterSpacing = 1.2.sp
),
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 8.dp)
)
}
@Composable
fun StimmedAppItem(app: AppInfo, onRemove: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFFE8F5E9) // Light Green for "Awake" apps
),
elevation = CardDefaults.cardElevation(4.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(app.icon)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = app.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold)
Text(text = "STIMMED", style = MaterialTheme.typography.labelSmall, color = Color(0xFF2E7D32))
}
IconButton(onClick = onRemove) {
Icon(Icons.Default.Delete, contentDescription = "Remove", tint = Color.Gray)
}
}
}
}
@Composable
fun SelectableAppItem(app: AppInfo, isSelected: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggleSelection)
.clickable { onToggle() }
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (app.icon != null) {
Image(
bitmap = app.icon.toBitmap().asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
} else {
Surface(
modifier = Modifier.size(48.dp),
color = MaterialTheme.colorScheme.primaryContainer,
shape = MaterialTheme.shapes.small
) {}
}
AppIcon(app.icon)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = app.name, style = MaterialTheme.typography.bodyLarge)
Text(
text = app.packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(text = app.packageName, style = MaterialTheme.typography.bodySmall, color = Color.Gray)
}
Checkbox(
checked = isSelected,
onCheckedChange = { onToggleSelection() }
onCheckedChange = { onToggle() }
)
}
}
@Preview(showBackground = true)
@Composable
fun AppItemPreview() {
MaterialTheme {
AppItem(
app = AppInfo("Example App", "com.example.app", null),
isSelected = true,
onToggleSelection = {}
fun AppIcon(icon: Drawable?) {
if (icon != null) {
Image(
bitmap = icon.toBitmap().asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(44.dp)
)
} else {
Surface(
modifier = Modifier.size(44.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
) {}
}
}
@@ -0,0 +1,131 @@
package com.example.stims
import android.app.*
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import androidx.core.app.NotificationCompat
class StimsService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var selectedPackages = mutableSetOf<String>()
private val handler = Handler(Looper.getMainLooper())
private val checkInterval = 10000L // Check every 10 seconds
private val monitorRunnable = object : Runnable {
override fun run() {
checkForegroundApp()
handler.postDelayed(this, checkInterval)
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
val packages = intent?.getStringArrayListExtra("selected_packages")
if (packages != null) {
selectedPackages = packages.toMutableSet()
updateNotification()
}
if (action == ACTION_STOP) {
stopSelf()
return START_NOT_STICKY
}
startForegroundService()
handler.removeCallbacks(monitorRunnable)
handler.post(monitorRunnable)
return START_STICKY
}
private fun startForegroundService() {
createNotificationChannel()
updateNotification()
handler.post(monitorRunnable)
}
private fun updateNotification() {
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Stims Daemon Running")
.setContentText("Monitoring ${selectedPackages.size} apps")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
}
private fun checkForegroundApp() {
val usageStatsManager = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val time = System.currentTimeMillis()
val events = usageStatsManager.queryEvents(time - 15000, time) // Increased window to match longer interval
val event = UsageEvents.Event()
var currentPackage: String? = null
while (events.hasNextEvent()) {
events.getNextEvent(event)
if (event.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND) {
currentPackage = event.packageName
}
}
if (currentPackage != null && selectedPackages.contains(currentPackage)) {
acquireWakeLock()
} else if (currentPackage != null) {
releaseWakeLock()
}
}
private fun acquireWakeLock() {
if (wakeLock == null || !wakeLock!!.isHeld) {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
@Suppress("DEPRECATION")
wakeLock = powerManager.newWakeLock(
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
"Stims::WakeLock"
)
wakeLock?.acquire(10 * 60 * 1000L) // 10 min timeout
}
}
private fun releaseWakeLock() {
if (wakeLock?.isHeld == true) {
wakeLock?.release()
}
wakeLock = null
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
val serviceChannel = NotificationChannel(
CHANNEL_ID, "Stims Service", NotificationManager.IMPORTANCE_LOW
)
manager.createNotificationChannel(serviceChannel)
}
}
}
override fun onDestroy() {
super.onDestroy()
handler.removeCallbacks(monitorRunnable)
releaseWakeLock()
}
companion object {
const val CHANNEL_ID = "StimsServiceChannel"
const val NOTIFICATION_ID = 1
const val ACTION_STOP = "com.example.stims.STOP"
}
}