diff --git a/README.md b/README.md
index 589920f..7816a03 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,40 @@ BoostX is a minimal yet powerful sound enhancement tool that allows users to boo
- Adjustable volume and boost sliders.
- Boost slider has two modes, discrete (default) and continuous (gradual) control.
- Real-time audio insights displaying output device details.
+- Optional **Start on Boot** — the boost is restored automatically after a reboot and stays
+ active in the background, without the app UI being open.
+
+## Start on Boot
+
+Enable **Start on Boot** in the app to have your saved boost and volume reapplied
+automatically after the device restarts.
+
+When the toggle is on (or a boost is active), BoostX runs a small foreground service that owns
+the audio effects, so the boost survives the UI being closed or swept out of recents. While it
+is running you'll see a persistent low-priority notification — *"BoostX active — Keeping volume
+boost at N%"* — which Android requires for any foreground service. Dismissing the boost from
+the app stops the service and the notification with it.
+
+Notes:
+
+- On Android 13+ the app asks for the notification permission the first time it needs to show
+ the service notification. If you deny it, the service still runs, but the notification is
+ hidden.
+- The service is declared as a `specialUse` foreground service. `mediaPlayback` is not usable
+ here because Android 15 forbids starting that type from `BOOT_COMPLETED`.
+- Some vendor ROMs aggressively kill background apps. If the boost doesn't come back after a
+ reboot, exempt BoostX from battery optimisation in the system settings.
+
+## Troubleshooting
+
+**The boost stops working when I play music (LineageOS / AudioFX ROMs).**
+
+System equalizers such as LineageOS' AudioFX attach their own effect to a music app's audio
+session. When they do, AudioFlinger *suspends* global (session 0) effects — which is where
+BoostX applies its boost — so the slider still shows a boost that you can no longer hear.
+
+This is platform behaviour rather than a BoostX bug. Disabling AudioFX (or whatever system
+equalizer your ROM ships) restores the boost.
## Screenshots
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fdd8501..6addcd9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,9 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/example/boostx/ActivityMain.kt b/app/src/main/java/com/example/boostx/ActivityMain.kt
index 950ca18..cf44f07 100644
--- a/app/src/main/java/com/example/boostx/ActivityMain.kt
+++ b/app/src/main/java/com/example/boostx/ActivityMain.kt
@@ -2,6 +2,7 @@ package com.example.boostx
import android.Manifest
import android.content.Context
+import android.content.SharedPreferences
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
@@ -46,6 +47,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var outputDeviceTextView: TextView
private lateinit var audioController: AudioController
+ private lateinit var prefs: SharedPreferences
private val handler = Handler(Looper.getMainLooper())
private val updateRunnable = object : Runnable {
@@ -62,7 +64,8 @@ class MainActivity : AppCompatActivity() {
checkPermissions()
- audioController = AudioController(this)
+ audioController = AudioController.getInstance(this)
+ prefs = getSharedPreferences("BoostXPrefs", Context.MODE_PRIVATE)
boostSlider = findViewById(R.id.boostSlider)
volumeSlider = findViewById(R.id.volumeSlider)
@@ -84,13 +87,15 @@ class MainActivity : AppCompatActivity() {
)
gradualBoostSwitch.setOnCheckedChangeListener { _, isChecked ->
handleGradualBoostSwitch(isChecked, originalBoostSliderProperties)
+ prefs.edit().putBoolean("gradual_boost", isChecked).apply()
}
bootStartSwitch.setOnCheckedChangeListener { _, isChecked ->
handleBootStartSwitch(isChecked)
+ prefs.edit().putBoolean("boot_start", isChecked).apply()
+ updateServiceState()
}
- val prefs = getSharedPreferences("BoostXPrefs", Context.MODE_PRIVATE)
val savedBoost = prefs.getFloat("boost_value", 0f)
val savedVolume = prefs.getFloat("volume_value", 100f)
val savedGradual = prefs.getBoolean("gradual_boost", false)
@@ -120,8 +125,14 @@ class MainActivity : AppCompatActivity() {
applyBoost(validatedBoost.toInt())
applyVolume(savedVolume.toInt())
- boostSlider.addOnChangeListener { _, value, _ -> applyBoost(value.toInt()) }
- volumeSlider.addOnChangeListener { _, value, _ -> applyVolume(value.toInt()) }
+ boostSlider.addOnChangeListener { _, value, _ ->
+ applyBoost(value.toInt())
+ prefs.edit().putFloat("boost_value", value).apply()
+ }
+ volumeSlider.addOnChangeListener { _, value, _ ->
+ applyVolume(value.toInt())
+ prefs.edit().putFloat("volume_value", value).apply()
+ }
findViewById(R.id.infoIcon).setOnClickListener {
showAppInfo()
@@ -131,8 +142,17 @@ class MainActivity : AppCompatActivity() {
}
private fun checkPermissions() {
+ val permissions = mutableListOf()
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
- ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.RECORD_AUDIO), 1001)
+ permissions.add(Manifest.permission.RECORD_AUDIO)
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
+ permissions.add(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+ if (permissions.isNotEmpty()) {
+ ActivityCompat.requestPermissions(this, permissions.toTypedArray(), 1001)
}
}
@@ -291,6 +311,16 @@ class MainActivity : AppCompatActivity() {
}
}
+ private fun updateServiceState() {
+ val boostActive = boostSlider.value > 0f
+ val bootStart = bootStartSwitch.isChecked
+ if (boostActive || bootStart) {
+ BoostService.start(this)
+ } else if (BoostService.isRunning) {
+ BoostService.stop(this)
+ }
+ }
+
override fun onResume() {
super.onResume()
handler.post(updateRunnable)
@@ -300,7 +330,6 @@ class MainActivity : AppCompatActivity() {
super.onPause()
handler.removeCallbacks(updateRunnable)
- val prefs = getSharedPreferences("BoostXPrefs", Context.MODE_PRIVATE)
prefs.edit().apply {
putFloat("boost_value", boostSlider.value)
putFloat("volume_value", volumeSlider.value)
@@ -308,11 +337,14 @@ class MainActivity : AppCompatActivity() {
putBoolean("boot_start", bootStartSwitch.isChecked)
apply()
}
+ updateServiceState()
}
override fun onDestroy() {
super.onDestroy()
- audioController.release()
+ if (!BoostService.isRunning) {
+ audioController.release()
+ }
handler.removeCallbacks(updateRunnable)
handler.removeCallbacksAndMessages(null)
}
diff --git a/app/src/main/java/com/example/boostx/AudioController.kt b/app/src/main/java/com/example/boostx/AudioController.kt
index 2f790da..40fe6b8 100644
--- a/app/src/main/java/com/example/boostx/AudioController.kt
+++ b/app/src/main/java/com/example/boostx/AudioController.kt
@@ -12,7 +12,7 @@ import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
-class AudioController(private val context: Context) {
+class AudioController private constructor(private val context: Context) {
private val audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var loudnessEnhancer: LoudnessEnhancer? = null
private var dynamicsProcessing: DynamicsProcessing? = null
@@ -27,6 +27,17 @@ class AudioController(private val context: Context) {
private var currentBoostLevel: Int = 0
+ companion object {
+ @Volatile
+ private var instance: AudioController? = null
+
+ fun getInstance(context: Context): AudioController {
+ return instance ?: synchronized(this) {
+ instance ?: AudioController(context.applicationContext).also { instance = it }
+ }
+ }
+ }
+
init {
setupEffects()
audioSessionID = audioManager.generateAudioSessionId()
@@ -235,5 +246,9 @@ class AudioController(private val context: Context) {
loudnessEnhancer?.release()
dynamicsProcessing?.release()
visualizer?.release()
+ loudnessEnhancer = null
+ dynamicsProcessing = null
+ visualizer = null
+ instance = null
}
}
diff --git a/app/src/main/java/com/example/boostx/BoostService.kt b/app/src/main/java/com/example/boostx/BoostService.kt
new file mode 100644
index 0000000..2c9103f
--- /dev/null
+++ b/app/src/main/java/com/example/boostx/BoostService.kt
@@ -0,0 +1,97 @@
+package com.example.boostx
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+
+class BoostService : Service() {
+
+ companion object {
+ private const val CHANNEL_ID = "boostx_service"
+ private const val NOTIFICATION_ID = 1
+
+ @Volatile
+ var isRunning = false
+ private set
+
+ fun start(context: Context) {
+ ContextCompat.startForegroundService(context, Intent(context, BoostService::class.java))
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, BoostService::class.java))
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ isRunning = true
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.boost_service_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ notificationManager.createNotificationChannel(channel)
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val prefs = getSharedPreferences("BoostXPrefs", Context.MODE_PRIVATE)
+ val boostLevel = prefs.getFloat("boost_value", 0f).toInt()
+ val volumeLevel = prefs.getFloat("volume_value", 100f).toInt()
+
+ val contentIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.boost_notification_title))
+ .setContentText(getString(R.string.boost_notification_text, boostLevel))
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setOngoing(true)
+ .setOnlyAlertOnce(true)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setContentIntent(contentIntent)
+ .build()
+
+ val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
+ } else {
+ 0
+ }
+
+ ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, type)
+
+ try {
+ val controller = AudioController.getInstance(this)
+ controller.applyBoost(boostLevel)
+ controller.applyVolume(volumeLevel)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ isRunning = false
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+}
diff --git a/app/src/main/java/com/example/boostx/BootReceiver.kt b/app/src/main/java/com/example/boostx/BootReceiver.kt
index a0b7cb2..a407f4a 100644
--- a/app/src/main/java/com/example/boostx/BootReceiver.kt
+++ b/app/src/main/java/com/example/boostx/BootReceiver.kt
@@ -7,15 +7,14 @@ import android.content.Intent
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
- intent.action == "android.intent.action.QUICKBOOT_POWERON") {
+ intent.action == "android.intent.action.QUICKBOOT_POWERON" ||
+ intent.action == "com.htc.intent.action.QUICKBOOT_POWERON") {
val prefs = context.getSharedPreferences("BoostXPrefs", Context.MODE_PRIVATE)
val bootStart = prefs.getBoolean("boot_start", false)
if (bootStart) {
- val i = Intent(context, MainActivity::class.java)
- i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- context.startActivity(i)
+ BoostService.start(context)
}
}
}
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index 6691e1d..b73b788 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -33,4 +33,7 @@
Oortje
Onbekend apparaat
Onbekend formaat
+ Achtergrond-boostservice
+ BoostX actief
+ Volumeboost actief op %1$d%%
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index abfa05f..550f4b4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -33,4 +33,7 @@
Earpiece
Unknown Device
Unknown Format
+ Background boost service
+ BoostX active
+ Keeping volume boost at %1$d%%
\ No newline at end of file