[android] Implement foreground notification service (#480)

A notification is shown when emulation is active. This, in theory should help preventing Eden for getting destroyed in the background. It is also a nice Q.O.L feature to have.

Credits go to t895 for the initial implementation. This was back-ported from older official Citra builds, although with crashes which i fixed myself.

Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/480
Reviewed-by: crueter <crueter@eden-emu.dev>
Co-authored-by: nyx <contact@innix.space>
Co-committed-by: nyx <contact@innix.space>
This commit is contained in:
nyx
2025-09-26 05:01:33 +02:00
committed by crueter
parent 10aca2f90c
commit 86ddb51a87
6 changed files with 130 additions and 0 deletions

View File

@@ -27,6 +27,8 @@ SPDX-License-Identifier: GPL-3.0-or-later
<uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS" android:required="false" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
@@ -93,6 +95,10 @@ SPDX-License-Identifier: GPL-3.0-or-later
<meta-data android:name="android.nfc.action.TECH_DISCOVERED" android:resource="@xml/nfc_tech_filter" />
</activity>
<service android:name="org.yuzu.yuzu_emu.utils.ForegroundService" android:foregroundServiceType="specialUse">
<property android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE" android:value="Keep emulation running in background"/>
</service>
<provider
android:name=".features.DocumentProvider"
android:authorities="${applicationId}.user"

View File

@@ -22,6 +22,17 @@ fun Context.getPublicFilesDir(): File = getExternalFilesDir(null) ?: filesDir
class YuzuApplication : Application() {
private fun createNotificationChannels() {
val name: CharSequence = getString(R.string.app_notification_channel_name)
val description = getString(R.string.app_notification_channel_description)
val foregroundService = NotificationChannel(
getString(R.string.app_notification_channel_id),
name,
NotificationManager.IMPORTANCE_DEFAULT
)
foregroundService.description = description
foregroundService.setSound(null, null)
foregroundService.vibrationPattern = null
val noticeChannel = NotificationChannel(
getString(R.string.notice_notification_channel_id),
getString(R.string.notice_notification_channel_name),
@@ -34,6 +45,7 @@ class YuzuApplication : Application() {
// or other notification behaviors after this
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(noticeChannel)
notificationManager.createNotificationChannel(foregroundService)
}
override fun onCreate() {

View File

@@ -7,6 +7,7 @@
package org.yuzu.yuzu_emu.activities
import android.annotation.SuppressLint
import android.app.Activity
import android.app.PendingIntent
import android.app.PictureInPictureParams
import android.app.RemoteAction
@@ -59,6 +60,7 @@ import org.yuzu.yuzu_emu.utils.ParamPackage
import org.yuzu.yuzu_emu.utils.ThemeHelper
import java.text.NumberFormat
import kotlin.math.roundToInt
import org.yuzu.yuzu_emu.utils.ForegroundService
class EmulationActivity : AppCompatActivity(), SensorEventListener {
private lateinit var binding: ActivityEmulationBinding
@@ -78,6 +80,8 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
private val emulationViewModel: EmulationViewModel by viewModels()
private var foregroundService: Intent? = null
override fun onCreate(savedInstanceState: Bundle?) {
Log.gameLaunched = true
ThemeHelper.setTheme(this)
@@ -128,6 +132,9 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
nfcReader = NfcReader(this)
nfcReader.initialize()
foregroundService = Intent(this, ForegroundService::class.java)
startForegroundService(foregroundService)
val preferences = PreferenceManager.getDefaultSharedPreferences(YuzuApplication.appContext)
if (!preferences.getBoolean(Settings.PREF_MEMORY_WARNING_SHOWN, false)) {
if (MemoryUtil.isLessThan(MemoryUtil.REQUIRED_MEMORY, MemoryUtil.totalMemory)) {
@@ -189,6 +196,12 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
stopMotionSensorListener()
}
override fun onDestroy() {
super.onDestroy()
stopForegroundService(this)
}
override fun onUserLeaveHint() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
if (BooleanSetting.PICTURE_IN_PICTURE.getBoolean() && !isInPictureInPictureMode) {
@@ -511,6 +524,12 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
companion object {
const val EXTRA_SELECTED_GAME = "SelectedGame"
fun stopForegroundService(activity: Activity) {
val startIntent = Intent(activity, ForegroundService::class.java)
startIntent.action = ForegroundService.ACTION_STOP
activity.startForegroundService(startIntent)
}
fun launch(activity: AppCompatActivity, game: Game) {
val launcher = Intent(activity, EmulationActivity::class.java)
launcher.putExtra(EXTRA_SELECTED_GAME, game)

View File

@@ -48,6 +48,7 @@ import java.io.BufferedOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import androidx.core.content.edit
import org.yuzu.yuzu_emu.activities.EmulationActivity
import kotlin.text.compareTo
class MainActivity : AppCompatActivity(), ThemeProvider {
@@ -188,6 +189,9 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
if (it) checkKeys()
}
// Dismiss previous notifications (should not happen unless a crash occurred)
EmulationActivity.stopForegroundService(this)
setInsets()
}
@@ -293,6 +297,11 @@ class MainActivity : AppCompatActivity(), ThemeProvider {
themeId = resId
}
override fun onDestroy() {
EmulationActivity.stopForegroundService(this)
super.onDestroy()
}
val getGamesDirectory =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { result ->
if (result != null) {

View File

@@ -0,0 +1,79 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2023 Citra Emulator Project
// Licensed under GPLv2 or any later version
// Refer to the license.txt file included.
package org.yuzu.yuzu_emu.utils
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.activities.EmulationActivity
/**
* A service that shows a permanent notification in the background to avoid the app getting
* cleared from memory by the system.
*/
class ForegroundService : Service() {
companion object {
const val EMULATION_RUNNING_NOTIFICATION = 0x1000
const val ACTION_STOP = "stop"
}
private fun showRunningNotification() {
// Intent is used to resume emulation if the notification is clicked
val contentIntent = PendingIntent.getActivity(
this,
0,
Intent(this, EmulationActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder =
NotificationCompat.Builder(this, getString(R.string.app_notification_channel_id))
.setSmallIcon(R.drawable.ic_stat_notification_logo)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.app_notification_running))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.setVibrate(null)
.setSound(null)
.setContentIntent(contentIntent)
startForeground(EMULATION_RUNNING_NOTIFICATION, builder.build())
}
override fun onBind(intent: Intent): IBinder? {
return null
}
override fun onCreate() {
showRunningNotification()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
try {
NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
stopForeground(STOP_FOREGROUND_REMOVE)
} catch (e: Exception) {
Log.error("Failed to stop foreground service")
}
stopSelfResult(startId)
return START_NOT_STICKY
}
if (intent != null) {
showRunningNotification()
}
return START_STICKY
}
override fun onDestroy() =
NotificationManagerCompat.from(this).cancel(EMULATION_RUNNING_NOTIFICATION)
}

View File

@@ -8,6 +8,11 @@
<string name="notice_notification_channel_id" translatable="false">noticesAndErrors</string>
<string name="notice_notification_channel_description">Shows notifications when something goes wrong.</string>
<string name="notification_permission_not_granted">Notification permission not granted!</string>
<string name="app_notification_channel_name" translatable="false">Eden</string>
<string name="app_notification_channel_id" translatable="false">Eden</string>
<string name="app_notification_channel_description">Eden Switch emulator notifications</string>
<string name="app_notification_running">Eden is Running</string>
<!-- Stats Overlay settings -->
<string name="enhanced_fps_suffix">(Enhanced)</string>