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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<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.POST_NOTIFICATIONS"/>

<application
android:allowBackup="true"
Expand Down Expand Up @@ -37,9 +40,19 @@
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter>
</receiver>

<service
android:name=".BoostService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Maintains a persistent system-wide audio loudness boost selected by the user" />
</service>

</application>

</manifest>
46 changes: 39 additions & 7 deletions app/src/main/java/com/example/boostx/ActivityMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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<TextView>(R.id.infoIcon).setOnClickListener {
showAppInfo()
Expand All @@ -131,8 +142,17 @@ class MainActivity : AppCompatActivity() {
}

private fun checkPermissions() {
val permissions = mutableListOf<String>()
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)
}
}

Expand Down Expand Up @@ -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)
Expand All @@ -300,19 +330,21 @@ 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)
putBoolean("gradual_boost", gradualBoostSwitch.isChecked)
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)
}
Expand Down
17 changes: 16 additions & 1 deletion app/src/main/java/com/example/boostx/AudioController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -235,5 +246,9 @@ class AudioController(private val context: Context) {
loudnessEnhancer?.release()
dynamicsProcessing?.release()
visualizer?.release()
loudnessEnhancer = null
dynamicsProcessing = null
visualizer = null
instance = null
}
}
97 changes: 97 additions & 0 deletions app/src/main/java/com/example/boostx/BoostService.kt
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 3 additions & 4 deletions app/src/main/java/com/example/boostx/BootReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/res/values-nl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@
<string name="device_earpiece">Oortje</string>
<string name="device_unknown">Onbekend apparaat</string>
<string name="format_unknown">Onbekend formaat</string>
<string name="boost_service_channel_name">Achtergrond-boostservice</string>
<string name="boost_notification_title">BoostX actief</string>
<string name="boost_notification_text">Volumeboost actief op %1$d%%</string>
</resources>
3 changes: 3 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,7 @@
<string name="device_earpiece">Earpiece</string>
<string name="device_unknown">Unknown Device</string>
<string name="format_unknown">Unknown Format</string>
<string name="boost_service_channel_name">Background boost service</string>
<string name="boost_notification_title">BoostX active</string>
<string name="boost_notification_text">Keeping volume boost at %1$d%%</string>
</resources>