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