Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76a59638de | |||
| e0e1a53128 | |||
| 1b5c060fd0 | |||
| cae4ae53a4 | |||
| cec33577be | |||
| a4aa7da072 | |||
| 44bd84e2c7 | |||
| ae74cb9ee7 | |||
| 908fc99342 | |||
| b1be1b5e43 | |||
| 1ad37bd6b7 | |||
| 3328708828 | |||
| b804d94fff |
@@ -1,25 +1,35 @@
|
||||
# stims
|
||||
# About
|
||||
|
||||
An Android app that keeps your screen awake while specific apps are in the foreground.
|
||||
Stims lets you select apps that should prevent your screen from turning off. When a "stimmed" app is active in the foreground, the screen stays on . When you switch to a different app, the screen resumes normal sleep behavior.
|
||||
|
||||
## What it does
|
||||
### Why use over alternatives
|
||||
|
||||
Stims lets you select apps that should prevent your screen from turning off. When a "stimmed" app is active in the foreground, the screen stays on at full brightness. When you switch to a different app, the screen resumes normal sleep behavior.
|
||||
Stims is designed to handle aggressive power managers like samsung's one UI , alternatives like caffeine won't work on these devices. If you encounter a problem please open a New Issue
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
Stims requires **Usage Access** permission to detect which app is in the foreground so it can keep the phone awake.
|
||||
### Usage Access (required)
|
||||
|
||||
On first launch the app will redirect you to the system settings screen. To enable it:
|
||||
Required to detect which app is in the foreground. On first launch the app will redirect you to the system settings screen.
|
||||
|
||||
1. Go to **Settings → Apps → Special app access → Usage access**
|
||||
2. Find **Stims** in the list and toggle it on
|
||||
2. Find **Stims** and toggle it on
|
||||
3. Return to the app
|
||||
|
||||
Without this permission the background service cannot detect foreground apps and the screen will not be kept awake.
|
||||
|
||||
### Display over other apps (required on Samsung / OneUI)
|
||||
|
||||
Samsung's One UI disables the standard wake lock mechanism, so Stims uses a transparent overlay window instead to keep the screen on. The overlay is invisible and non-interactive.
|
||||
|
||||
1. Go to **Settings → Apps → Stims → Display over other apps**
|
||||
2. Toggle it on
|
||||
3. Return to the app — the warning banner will disappear
|
||||
|
||||
On stock Android and most other devices this permission is not needed and the app will work without it. It can also be enabled manually via the in-app settings for any device.
|
||||
|
||||
---
|
||||
|
||||
# Building from source
|
||||
@@ -27,7 +37,7 @@ Without this permission the background service cannot detect foreground apps and
|
||||
**Requirements**
|
||||
|
||||
- Android Studio or the Android SDK command-line tools
|
||||
- JDK 8+
|
||||
- JDK 11+
|
||||
|
||||
**Steps**
|
||||
|
||||
@@ -43,11 +53,11 @@ The APK will be output to:
|
||||
app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
For a release build:
|
||||
For a release bundle (AAB):
|
||||
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
# output: app/build/outputs/apk/release/app-release-unsigned.apk
|
||||
./gradlew bundleRelease
|
||||
# output: app/build/outputs/bundle/release/app-release.aab
|
||||
```
|
||||
|
||||
---
|
||||
@@ -55,4 +65,4 @@ For a release build:
|
||||
## Compatibility
|
||||
|
||||
- **Minimum:** Android 7.0 (Nougat, API 24)
|
||||
- **Target:** Android 14 (API 34)
|
||||
- **Target:** Android 15 (API 35)
|
||||
|
||||
+23
-17
@@ -5,14 +5,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace 'acidburn.stims'
|
||||
compileSdk 34
|
||||
compileSdk 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId "acidburn.stims"
|
||||
minSdk 24
|
||||
targetSdk 34
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
targetSdk 35
|
||||
versionCode 5
|
||||
versionName "1.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -28,17 +28,18 @@ android {
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
jvmTarget = '11'
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
buildConfig true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion '1.5.8'
|
||||
kotlinCompilerExtensionVersion '1.5.15'
|
||||
}
|
||||
packagingOptions {
|
||||
resources {
|
||||
@@ -48,24 +49,29 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
||||
implementation platform('androidx.compose:compose-bom:2023.10.01')
|
||||
implementation 'androidx.core:core-ktx:1.15.0'
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7'
|
||||
implementation 'androidx.activity:activity-compose:1.10.1'
|
||||
implementation platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.ui:ui-graphics'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.material:material-icons-extended'
|
||||
|
||||
// Added for XML theme support
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation 'com.google.android.material:material:1.12.0'
|
||||
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2023.10.01')
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
|
||||
androidTestImplementation platform('androidx.compose:compose-bom:2025.02.00')
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
debugImplementation 'androidx.compose.ui:ui-test-manifest'
|
||||
}
|
||||
|
||||
// Alias for CI/CD systems or scripts that expect the standard Java 'testClasses' task
|
||||
tasks.register("testClasses") {
|
||||
// This maps to compiling the debug unit tests
|
||||
dependsOn "compileDebugUnitTestSources"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<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" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Process
|
||||
@@ -14,13 +15,26 @@ import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForward
|
||||
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.ArrowForward
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -44,18 +58,30 @@ data class AppInfo(
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private var showOverlayWarning by mutableStateOf(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
prefs = getSharedPreferences("stims_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
|
||||
setContent {
|
||||
StimsTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppListScreen(prefs)
|
||||
AppListScreen(
|
||||
prefs = prefs,
|
||||
showOverlayWarning = showOverlayWarning,
|
||||
onOpenOverlaySettings = {
|
||||
startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.fromParts("package", packageName, null)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +93,10 @@ class MainActivity : ComponentActivity() {
|
||||
Toast.makeText(this, "Please enable Usage Stats permission", Toast.LENGTH_LONG).show()
|
||||
startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||
}
|
||||
val needsOverlay = StimsService.OVERLAY_VENDORS.any {
|
||||
Build.MANUFACTURER.equals(it, ignoreCase = true)
|
||||
}
|
||||
showOverlayWarning = needsOverlay && !Settings.canDrawOverlays(this)
|
||||
}
|
||||
|
||||
private fun hasUsageStatsPermission(context: Context): Boolean {
|
||||
@@ -83,13 +113,17 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppListScreen(prefs: SharedPreferences) {
|
||||
fun AppListScreen(
|
||||
prefs: SharedPreferences,
|
||||
showOverlayWarning: Boolean,
|
||||
onOpenOverlaySettings: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val packageManager = context.packageManager
|
||||
|
||||
|
||||
var allApps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
|
||||
val stimmedPackages = remember {
|
||||
mutableStateListOf<String>().apply {
|
||||
addAll(prefs.getStringSet("stimmed_apps", emptySet()) ?: emptySet())
|
||||
@@ -98,13 +132,16 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
|
||||
val tempSelected = remember { mutableStateListOf<String>() }
|
||||
|
||||
LaunchedEffect(stimmedPackages.toList()) {
|
||||
var forceOverlay by remember { mutableStateOf(prefs.getBoolean("force_overlay", false)) }
|
||||
|
||||
LaunchedEffect(stimmedPackages.toList(), forceOverlay) {
|
||||
prefs.edit().putStringSet("stimmed_apps", stimmedPackages.toSet()).apply()
|
||||
|
||||
|
||||
val intent = Intent(context, StimsService::class.java).apply {
|
||||
putStringArrayListExtra("selected_packages", ArrayList(stimmedPackages))
|
||||
putExtra(StimsService.EXTRA_FORCE_OVERLAY, forceOverlay)
|
||||
}
|
||||
|
||||
|
||||
if (stimmedPackages.isEmpty()) {
|
||||
context.stopService(intent)
|
||||
} else {
|
||||
@@ -122,7 +159,7 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
}
|
||||
val resolveInfos = packageManager.queryIntentActivities(intent, 0)
|
||||
|
||||
|
||||
resolveInfos.map { resolveInfo ->
|
||||
AppInfo(
|
||||
name = resolveInfo.loadLabel(packageManager).toString(),
|
||||
@@ -136,21 +173,42 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
}
|
||||
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var showSettings by remember { mutableStateOf(false) }
|
||||
|
||||
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))
|
||||
allApps.filter {
|
||||
it.packageName !in stimmedPackages &&
|
||||
(searchQuery.isBlank() || it.name.contains(searchQuery, ignoreCase = true) ||
|
||||
it.packageName.contains(searchQuery, ignoreCase = true))
|
||||
}
|
||||
}
|
||||
|
||||
if (showSettings) {
|
||||
SettingsScreen(
|
||||
forceOverlay = forceOverlay,
|
||||
onForceOverlayChange = { checked ->
|
||||
forceOverlay = checked
|
||||
prefs.edit().putBoolean("force_overlay", checked).apply()
|
||||
},
|
||||
onOpenOverlaySettings = onOpenOverlaySettings,
|
||||
onBack = { showSettings = false }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Stims Manager", fontWeight = FontWeight.Bold) }
|
||||
title = { Text("Stims Manager", fontWeight = FontWeight.Bold) },
|
||||
actions = {
|
||||
IconButton(onClick = { showSettings = true }) {
|
||||
Icon(Icons.Default.Settings, contentDescription = "Settings")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
@@ -160,6 +218,9 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
}
|
||||
} else {
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
if (showOverlayWarning) {
|
||||
OverlayWarningBanner(onClick = onOpenOverlaySettings)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
@@ -173,9 +234,7 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
if (stimList.isNotEmpty()) {
|
||||
item {
|
||||
SectionHeader("STAY AWAKE (STIMMED)")
|
||||
}
|
||||
item { SectionHeader("STAY AWAKE (STIMMED)") }
|
||||
items(stimList) { app ->
|
||||
StimmedAppItem(app = app) {
|
||||
stimmedPackages.remove(app.packageName)
|
||||
@@ -183,9 +242,7 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionHeader("ALL APPS")
|
||||
}
|
||||
item { SectionHeader("ALL APPS") }
|
||||
items(availableApps) { app ->
|
||||
SelectableAppItem(
|
||||
app = app,
|
||||
@@ -197,7 +254,7 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
stimmedPackages.addAll(tempSelected)
|
||||
@@ -216,6 +273,134 @@ fun AppListScreen(prefs: SharedPreferences) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
forceOverlay: Boolean,
|
||||
onForceOverlayChange: (Boolean) -> Unit,
|
||||
onOpenOverlaySettings: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val canDrawOverlays = Settings.canDrawOverlays(context)
|
||||
val isVendorDevice = StimsService.OVERLAY_VENDORS.any {
|
||||
Build.MANUFACTURER.equals(it, ignoreCase = true)
|
||||
}
|
||||
val effectiveOverlay = forceOverlay || isVendorDevice
|
||||
val overlayActive = effectiveOverlay && canDrawOverlays
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Settings") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
|
||||
SettingsSectionHeader("DISPLAY")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Force awake overlay", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = when {
|
||||
overlayActive && isVendorDevice -> "Active — required on this device"
|
||||
overlayActive -> "Active — screen kept on via overlay"
|
||||
effectiveOverlay -> "Enabled — overlay permission required"
|
||||
else -> "Inactive"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when {
|
||||
overlayActive -> Color(0xFF2E7D32)
|
||||
effectiveOverlay -> MaterialTheme.colorScheme.error
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = effectiveOverlay,
|
||||
enabled = !isVendorDevice,
|
||||
onCheckedChange = { checked ->
|
||||
onForceOverlayChange(checked)
|
||||
if (checked && !canDrawOverlays) {
|
||||
onOpenOverlaySettings()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
SettingsSectionHeader("ABOUT")
|
||||
|
||||
SettingsInfoRow(label = "Version", value = BuildConfig.VERSION_NAME)
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
SettingsInfoRow(label = "Author", value = "acidburnmonkey")
|
||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
context.startActivity(
|
||||
Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/acidburnmonkey/stims"))
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "GitHub",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "Open GitHub",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsInfoRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SectionHeader(title: String) {
|
||||
Text(
|
||||
@@ -231,13 +416,16 @@ fun SectionHeader(title: String) {
|
||||
|
||||
@Composable
|
||||
fun StimmedAppItem(app: AppInfo, onRemove: () -> Unit) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val cardColor = if (dark) Color(0xFF1B3A2B) else Color(0xFFE8F5E9)
|
||||
val nameColor = if (dark) Color(0xFFE8F5E9) else Color(0xFF1B5E20)
|
||||
val stimmedColor = if (dark) Color(0xFFFFB74D) else Color(0xFFEF6C00)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFFE8F5E9)
|
||||
),
|
||||
colors = CardDefaults.cardColors(containerColor = cardColor),
|
||||
elevation = CardDefaults.cardElevation(4.dp)
|
||||
) {
|
||||
Row(
|
||||
@@ -247,11 +435,11 @@ fun StimmedAppItem(app: AppInfo, onRemove: () -> Unit) {
|
||||
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))
|
||||
Text(text = app.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Bold, color = nameColor)
|
||||
Text(text = "STIMMED", style = MaterialTheme.typography.labelSmall, color = stimmedColor)
|
||||
}
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "Remove", tint = Color.Gray)
|
||||
Icon(Icons.Default.Delete, contentDescription = "Remove", tint = if (dark) Color(0xFFB0BEC5) else Color.Gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,3 +483,46 @@ fun AppIcon(icon: Drawable?) {
|
||||
) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OverlayWarningBanner(onClick: () -> Unit) {
|
||||
val warningColor = Color(0xFFEA580C)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
.border(width = 1.5.dp, color = warningColor, shape = MaterialTheme.shapes.small)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Visibility,
|
||||
contentDescription = null,
|
||||
tint = warningColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Overlay permission required (Samsung)",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF1E40AF)
|
||||
)
|
||||
Text(
|
||||
text = "Tap → Allow display over other apps",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = warningColor
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
|
||||
contentDescription = null,
|
||||
tint = warningColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,34 @@ import android.app.usage.UsageEvents
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class StimsService : Service() {
|
||||
|
||||
// Samsung and certain OEMs disable SCREEN_BRIGHT_WAKE_LOCK; use a 1x1 overlay with
|
||||
// FLAG_KEEP_SCREEN_ON instead, which the WindowManager honours on all devices.
|
||||
private var useOverlayStrategy = OVERLAY_VENDORS.any {
|
||||
Build.MANUFACTURER.equals(it, ignoreCase = true)
|
||||
}
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private var overlayView: View? = null
|
||||
private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
|
||||
|
||||
private var selectedPackages = mutableSetOf<String>()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val checkInterval = 10000L // Check every 10 seconds
|
||||
private var lastForegroundPackage: String? = null
|
||||
|
||||
private val monitorRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
@@ -34,17 +49,24 @@ class StimsService : Service() {
|
||||
|
||||
if (packages != null) {
|
||||
selectedPackages = packages.toMutableSet()
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Updated stimmed packages: $selectedPackages")
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
val forceOverlay = intent?.getBooleanExtra(EXTRA_FORCE_OVERLAY, false) ?: false
|
||||
useOverlayStrategy = forceOverlay || OVERLAY_VENDORS.any {
|
||||
Build.MANUFACTURER.equals(it, ignoreCase = true)
|
||||
}
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "useOverlayStrategy=$useOverlayStrategy forceOverlay=$forceOverlay")
|
||||
|
||||
if (action == ACTION_STOP) {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Stop action received")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Service starting")
|
||||
startForegroundService()
|
||||
handler.removeCallbacks(monitorRunnable)
|
||||
handler.post(monitorRunnable)
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
@@ -52,10 +74,13 @@ class StimsService : Service() {
|
||||
private fun startForegroundService() {
|
||||
createNotificationChannel()
|
||||
updateNotification()
|
||||
handler.removeCallbacks(monitorRunnable)
|
||||
handler.post(monitorRunnable)
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
// Ensure the channel exists before startForeground(); onStartCommand may call
|
||||
createNotificationChannel()
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Stims Daemon Running")
|
||||
.setContentText("Monitoring ${selectedPackages.size} apps")
|
||||
@@ -68,30 +93,41 @@ class StimsService : Service() {
|
||||
private fun checkForegroundApp() {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!powerManager.isInteractive) {
|
||||
releaseWakeLock()
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Screen not interactive, releasing screen lock")
|
||||
releaseScreenLock()
|
||||
return
|
||||
}
|
||||
|
||||
val usageStatsManager = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
|
||||
val time = System.currentTimeMillis()
|
||||
val events = usageStatsManager.queryEvents(time - 15000, time)
|
||||
val events = usageStatsManager.queryEvents(time - checkInterval * 2, time)
|
||||
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 (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED) {
|
||||
lastForegroundPackage = event.packageName
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPackage != null && selectedPackages.contains(currentPackage)) {
|
||||
acquireWakeLock()
|
||||
} else if (currentPackage != null) {
|
||||
releaseWakeLock()
|
||||
val shouldHold = lastForegroundPackage != null && selectedPackages.contains(lastForegroundPackage)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Poll: foreground=$lastForegroundPackage shouldHold=$shouldHold wakeLockHeld=${wakeLock?.isHeld}")
|
||||
|
||||
if (shouldHold) {
|
||||
acquireScreenLock()
|
||||
} else {
|
||||
releaseScreenLock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireScreenLock() {
|
||||
if (useOverlayStrategy) acquireOverlay() else acquireWakeLock()
|
||||
}
|
||||
|
||||
private fun releaseScreenLock() {
|
||||
if (useOverlayStrategy) releaseOverlay() else releaseWakeLock()
|
||||
}
|
||||
|
||||
private fun acquireWakeLock() {
|
||||
if (wakeLock == null || !wakeLock!!.isHeld) {
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
@@ -100,17 +136,47 @@ class StimsService : Service() {
|
||||
PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP,
|
||||
"Stims::WakeLock"
|
||||
)
|
||||
wakeLock?.acquire(10 * 60 * 1000L) // 10 min timeout
|
||||
@Suppress("WakelockTimeout")
|
||||
wakeLock?.acquire()
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Wake lock acquired for $lastForegroundPackage")
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseWakeLock() {
|
||||
if (wakeLock?.isHeld == true) {
|
||||
wakeLock?.release()
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Wake lock released")
|
||||
}
|
||||
wakeLock = null
|
||||
}
|
||||
|
||||
private fun acquireOverlay() {
|
||||
if (overlayView != null) return
|
||||
if (!Settings.canDrawOverlays(this)) {
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Overlay permission not granted, cannot acquire overlay")
|
||||
return
|
||||
}
|
||||
val params = WindowManager.LayoutParams(
|
||||
1, 1,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
|
||||
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
|
||||
PixelFormat.TRANSLUCENT
|
||||
)
|
||||
overlayView = View(this)
|
||||
windowManager.addView(overlayView, params)
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Overlay acquired for $lastForegroundPackage")
|
||||
}
|
||||
|
||||
private fun releaseOverlay() {
|
||||
overlayView?.let {
|
||||
windowManager.removeView(it)
|
||||
overlayView = null
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Overlay released")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
@@ -126,12 +192,17 @@ class StimsService : Service() {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
handler.removeCallbacks(monitorRunnable)
|
||||
releaseWakeLock()
|
||||
releaseScreenLock()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "StimsServiceChannel"
|
||||
const val NOTIFICATION_ID = 1
|
||||
const val ACTION_STOP = "acidburn.stims.STOP"
|
||||
const val EXTRA_FORCE_OVERLAY = "force_overlay"
|
||||
private const val TAG = "StimsService"
|
||||
|
||||
// Vendors that disable SCREEN_BRIGHT_WAKE_LOCK — use overlay strategy instead
|
||||
val OVERLAY_VENDORS = setOf("samsung","Realme")
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
+2
-2
@@ -5,8 +5,8 @@ buildscript {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.2.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22"
|
||||
classpath 'com.android.tools.build:gradle:8.7.3'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 889 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 175 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,31 @@
|
||||
Categories:
|
||||
- System
|
||||
License: GPL-3.0-only
|
||||
AuthorName: acidburnmonkey
|
||||
AuthorEmail: developercontact.gnarly348@slmail.me
|
||||
WebSite: https://acidburnmonkey.github.io/stims/
|
||||
SourceCode: https://github.com/acidburnmonkey/stims
|
||||
IssueTracker: https://github.com/acidburnmonkey/stims/issues
|
||||
|
||||
AutoName: Stims
|
||||
|
||||
RepoType: git
|
||||
Repo: https://github.com/acidburnmonkey/stims
|
||||
|
||||
Builds:
|
||||
- versionName: '1.2'
|
||||
versionCode: 4
|
||||
commit: TO_BE_TAGGED
|
||||
subdir: app
|
||||
sudo:
|
||||
- echo "deb https://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list
|
||||
- apt-get update
|
||||
- apt-get install -y -t trixie openjdk-21-jdk-headless
|
||||
- update-alternatives --auto java
|
||||
gradle:
|
||||
- yes
|
||||
|
||||
AutoUpdateMode: Version
|
||||
UpdateCheckMode: Tags
|
||||
CurrentVersion: '1.2'
|
||||
CurrentVersionCode: 4
|
||||
@@ -1,3 +1,4 @@
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.configuration-cache=false
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
#Sat Mar 28 00:18:44 EDT 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
Reference in New Issue
Block a user