implemented daemon
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user