Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />
Expand Down Expand Up @@ -86,5 +88,15 @@
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="keep the IDE process alive while backgrounded" />
</service>

<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>
</application>
</manifest>
31 changes: 31 additions & 0 deletions app/src/main/kotlin/org/cosmicide/service/BootReceiver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* This file is part of Cosmic IDE.
* Cosmic IDE is a free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
* Cosmic IDE is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
* You should have received a copy of the GNU General Public License along with Cosmic IDE. If not, see <https://www.gnu.org/licenses/>.
*/

package org.cosmicide.service

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log

/**
* Receiver that starts the KeepAliveService on device boot.
* Similar to Termux's boot receiver pattern.
*/
class BootReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
Log.i(TAG, "Boot completed, starting KeepAliveService")
KeepAliveService.start(context)
}
}

companion object {
private const val TAG = "BootReceiver"
}
}
160 changes: 146 additions & 14 deletions app/src/main/kotlin/org/cosmicide/service/KeepAliveService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,61 +17,193 @@ import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import org.cosmicide.MainActivity
import org.cosmicide.R

/**
* Foreground service that keeps the app process alive in the background, like Termux.
*
* Features:
* - Foreground service with persistent notification
* - WakeLock support to prevent CPU from sleeping
* - Notification actions (stop service)
* - Auto-restart with START_STICKY
* - Proper cleanup on destroy
*/
class KeepAliveService : Service() {

private var wakeLock: PowerManager.WakeLock? = null
private var isServiceStopping = false

override fun onCreate() {
super.onCreate()
startForegroundCompat()
Log.v(TAG, "onCreate")
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NotificationId, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NotificationId, buildNotification())
Log.v(TAG, "onStartCommand action=${intent?.action}")

when (intent?.action) {
ACTION_STOP -> {
actionStopService()
return START_NOT_STICKY
}
ACTION_ACQUIRE_WAKE_LOCK -> {
actionAcquireWakeLock()
}
ACTION_RELEASE_WAKE_LOCK -> {
actionReleaseWakeLock()
}
}

runStartForeground()
return START_STICKY
}

override fun onBind(intent: Intent?): IBinder? = null

private fun startForegroundCompat() = onStartCommand(null, 0, 0)
override fun onDestroy() {
Log.v(TAG, "onDestroy")
actionReleaseWakeLock()
super.onDestroy()
}

private fun runStartForeground() {
setupNotificationChannel()
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}

private fun buildNotification(): Notification {
val channelId = "keepalive"
private fun runStopForeground() {
stopForeground(STOP_FOREGROUND_REMOVE)
}

private fun actionStopService() {
isServiceStopping = true
actionReleaseWakeLock()
runStopForeground()
stopSelf()
}

private fun actionAcquireWakeLock() {
if (wakeLock == null) {
val powerManager = getSystemService(POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"$packageName:keep_alive"
).apply {
acquire(10 * 60 * 1000L) // 10 minutes
}
}
updateNotification()
}

private fun actionReleaseWakeLock() {
wakeLock?.let {
if (it.isHeld) {
it.release()
}
}
wakeLock = null
if (!isServiceStopping) {
updateNotification()
}
}

private fun updateNotification() {
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, buildNotification())
}

private fun setupNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId, "Foreground service", NotificationManager.IMPORTANCE_MIN
)
CHANNEL_ID,
getString(R.string.keep_alive_notification_channel),
NotificationManager.IMPORTANCE_MIN
).apply {
description = getString(R.string.keep_alive_notification_channel_description)
setShowBadge(false)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}

private fun buildNotification(): Notification {
val contentIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)

val stopIntent = PendingIntent.getService(
this, 0,
Intent(this, KeepAliveService::class.java).apply {
action = ACTION_STOP
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)

val wakeLockHeld = wakeLock?.isHeld == true
val wakeLockText = if (wakeLockHeld) {
getString(R.string.notification_action_wake_unlock)
} else {
getString(R.string.notification_action_wake_lock)
}
val wakeLockAction = if (wakeLockHeld) ACTION_RELEASE_WAKE_LOCK else ACTION_ACQUIRE_WAKE_LOCK

val wakeLockIntent = PendingIntent.getService(
this, 1,
Intent(this, KeepAliveService::class.java).apply {
action = wakeLockAction
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)

return Notification.Builder(this, channelId)
val wakeLockIcon = if (wakeLockHeld) {
android.R.drawable.ic_lock_idle_lock
} else {
android.R.drawable.ic_lock_lock
}

return Notification.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.keep_alive_notification_text))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setOngoing(true)
.setContentIntent(contentIntent)
.addAction(wakeLockIcon, wakeLockText, wakeLockIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, getString(R.string.notification_action_stop), stopIntent)
.build()
}

companion object {
private const val NotificationId = 1
private const val TAG = "KeepAliveService"
private const val CHANNEL_ID = "keepalive"
private const val NOTIFICATION_ID = 1

const val ACTION_STOP = "org.cosmicide.service.action.STOP"
const val ACTION_ACQUIRE_WAKE_LOCK = "org.cosmicide.service.action.ACQUIRE_WAKE_LOCK"
const val ACTION_RELEASE_WAKE_LOCK = "org.cosmicide.service.action.RELEASE_WAKE_LOCK"

fun start(context: Context) {
context.startForegroundService(Intent(context, KeepAliveService::class.java))
}

fun stop(context: Context) {
context.startService(
Intent(context, KeepAliveService::class.java).apply {
action = ACTION_STOP
}
)
}
}
}
}
5 changes: 5 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@

<string name="terminal">Terminal</string>
<string name="keep_alive_notification_text">Cosmic IDE is running in the background</string>
<string name="keep_alive_notification_channel">Foreground service</string>
<string name="keep_alive_notification_channel_description">Keeps the IDE process alive while running in the background</string>
<string name="notification_action_wake_lock">Acquire wake lock</string>
<string name="notification_action_wake_unlock">Release wake lock</string>
<string name="notification_action_stop">Stop</string>
<string name="lsp_diagnostic_copy">Copy</string>
<string name="lsp_diagnostic_quick_fixes">Quick fixes</string>
</resources>
Loading