Repository: luanti-org/luanti
Pull request #13125: Android: Persistent notification while ingame
Author: login: srifqi
Labels: name: Android
name: >= Two approvals ✅ ✅
name: Feature ✨
description: PRs that add or enhance a feature
name: Concept approved
description: Approved by a core dev: PRs welcomed!
name: UI/UX
Created: 2023-01-08T02:00:40Z | Merged: 2025-04-24T11:38:52Z

**Goal of the PR**
This PR adds a notification for Android player when playing inside a world/server.

**How does the PR work?**
Notify player before the main game loop and remove notification after the main game loop.

**Does it resolve any reported issue?**
Yes, this PR tries to fix #13115 (read the alternatives).

**Does this relate to a goal in the roadmap?**
Probably, this PR tries to fix a reported UX issue.

## To do
This PR is Ready for Review.

Known issue: The notification still exists after removing Minetest from the recent application list while the player is still inside a world/server. Restarting Minetest (or via tapping the notification) will clear it up. This issue also exists in other application and seems to be OEM-dependent.

## How to test
1. Run Minetest and play a world/server.
2. Check that a Minetest notification exists.
3. Press Home or go to recent application list.
4. Check that the Minetest notification still exists.
5. Enter Minetest application.
6. Go back to main menu.
7. Check that the Minetest notification has been removed.
8. Play a world/server again.
9. Check that a Minetest notification exists.
10. Exit to OS.
11. Check that the Minetest notification has been removed.

Linked issue #13115: Running in the background on Android
## Problem

When pressing the home button on Android, the game seemingly continues to run in the background. There is no sticky notification to indicate this, and I was entirely unaware until I had the mineclone2 world select menu open and heard music in the background. This can cause battery drain.

## Solutions

This should probably be configurable.

On singleplayer worlds and menus, if the user presses the home button the game should probably be paused and frozen, and on multiplayer worlds it should probably disconnect.

## Alternatives

Alternatively, Minetest should have a sticky notification that indicates it is running.

Linked issue comments: user: login: ghost
created_at: 2023-01-22T10:48:19Z
body: A sticky notification on android is sort of an anti-pattern applications employ, this stops android from specific sorts of battery optimizations. As it makes the UI much clunkier I think this shouldn't be added at all.
The application overview already makes it clear that the application is still running and it's state
author_association: NONE
user: login: sfan5
created_at: 2023-01-29T21:46:45Z
body: I tend to agree, however if Android kills the game while it's in the background it won't be shut down properly and restarting it is far from seamless.
Does Minetest already enter the pause dialog when backgrounded?
author_association: MEMBER
user: login: Unacceptium
created_at: 2023-10-12T08:03:48Z
body: i like that mt stays opened, because i can check websites, or get a music to play while mt is running in background. but closing it isn't that hard, you just swipe it from the recent apps. if you playing a server, mt will disconnect your player as timed out. for me, this is better than ok, but a notification indicating that minetest is still running, just in the background, would be a not that bad idea, especially for less experienced android users. if android kills the game in the background, you just need to disable battery optimization in the system's app settings.(works for me 100% even on android 12)
(this is my opinion)
author_association: CONTRIBUTOR
user: login: srifqi
created_at: 2024-01-03T17:00:05Z
body: Minetest for Android was fully paused (as in not responding) when the main game app is unfocused (see Issue #10842). After PR minetest/irrlicht#255, that is no longer the case. PR #14058 attempts to pause rendering while still running networking code and other non-rendering code (e.g. server code for singleplayer/as a host).
author_association: CONTRIBUTOR
user: login: Unacceptium
created_at: 2024-01-03T19:03:05Z
body: @srifqi i don't even understand a word you said but i'm totally ok with the game going "unfocused" and just lowers the fps.
like i can hear when someone hit me of an advtrain passes, even if i'm in a different app.
author_association: CONTRIBUTOR
user: login: srifqi
created_at: 2024-01-03T20:00:07Z
body: Ah, sorry for that. It was midnight here. 💤

By the way, since the 5.8.0 is already out, have you tested it? What changes do we want/need to have regarding this issue?
author_association: CONTRIBUTOR
user: login: grorp
created_at: 2024-01-03T20:40:42Z
body: >  like i can hear when someone hit me of an advtrain passes, even if i'm in a different app.

This should still work after #14058, sounds are not affected by that PR. However, the sounds will be quieter after #14083 :)

> By the way, since the 5.8.0 is already out, have you tested it? What changes do we want/need to have regarding this issue?

From Unacceptium's feedback, it sounds to me like the current behavior is already what they want (the skipped rendering should, after all, not be observable), except for this part:

> but a notification indicating that minetest is still running, just in the background, would be a not that bad idea, especially for less experienced android users

I'm honestly not sure about #13125, but I haven't yet tried it or thought about it much. What especially bothers me is this:

> Known issue: The notification still exists after removing Minetest from the recent application list while the player is still inside a world/server. Restarting Minetest (or via tapping the notification) will clear it up. This issue also exists in other application and seems to be OEM-dependent.

No notification might be better than a buggy notification.
author_association: MEMBER
user: login: Unacceptium
created_at: 2024-01-04T05:25:17Z
body: @srifqi 
> Ah, sorry for that. It was midnight here. 💤
> 
> By the way, since the 5.8.0 is already out, have you tested it? What changes do we want/need to have regarding this issue?

i installed 5.8:
the game never get unfocused,
it runs in background with Every frame processed
i sent it to background for a min and this happened:
i hear trains passing. and getting back to MT i am at the main menu. NO indications that my MT shut down, i heard the trains passing before i ooened MT!!!!!
for me this is bad.

MT does not get FPS unfocused at all. if notif.bar open, chat open, sent to background, etc. is this the goal?
but MT just shut down! from a sec to another with no indications!
for checking similarity, rollertest goes unfocused for all of these and if it shuts down before i get back, RT shows "shutting down" for a short second.

sorry for spamming this message.
author_association: CONTRIBUTOR
user: login: ghost
created_at: 2024-01-04T05:52:30Z
body: > 1-they? i Directly stated that this is only my opinion!

the word "they" in this case is third person singular, not plural.
author_association: NONE

Diff:
diff --git a/android/app/src/main/java/net/minetest/minetest/GameActivity.java b/android/app/src/main/java/net/minetest/minetest/GameActivity.java
index 22e861fb0f345..770c1c4f25265 100644
--- a/android/app/src/main/java/net/minetest/minetest/GameActivity.java
+++ b/android/app/src/main/java/net/minetest/minetest/GameActivity.java
@@ -22,14 +22,19 @@
 
 import org.libsdl.app.SDLActivity;
 
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
 import android.content.Intent;
 import android.content.ActivityNotFoundException;
 import android.net.Uri;
-import android.os.Bundle;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
 import android.text.InputType;
 import android.util.Log;
 import android.view.KeyEvent;
-import android.view.View;
 import android.view.WindowManager;
 import android.view.inputmethod.InputMethodManager;
 import android.widget.Button;
@@ -91,6 +96,9 @@ protected void onStop() {
 		saveSettings();
 	}
 
+	private NotificationManager mNotifyManager;
+	private boolean gameNotificationShown = false;
+
 	public void showTextInputDialog(String hint, String current, int editType) {
 		runOnUiThread(() -> showTextInputDialogUI(hint, current, editType));
 	}
@@ -263,4 +271,67 @@ public String getLanguage() {
 	public boolean hasPhysicalKeyboard() {
 		return getContext().getResources().getConfiguration().keyboard != Configuration.KEYBOARD_NOKEYS;
 	}
+
+	// TODO: share code with UnzipService.createNotification
+	private void updateGameNotification() {
+		if (mNotifyManager == null) {
+			mNotifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+		}
+
+		if (!gameNotificationShown) {
+			mNotifyManager.cancel(MainActivity.NOTIFICATION_ID_GAME);
+			return;
+		}
+
+		Notification.Builder builder;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			builder = new Notification.Builder(this, MainActivity.NOTIFICATION_CHANNEL_ID);
+		} else {
+			builder = new Notification.Builder(this);
+		}
+
+		Intent notificationIntent = new Intent(this, GameActivity.class);
+		notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+			| Intent.FLAG_ACTIVITY_SINGLE_TOP);
+		int pendingIntentFlag = 0;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+			pendingIntentFlag = PendingIntent.FLAG_MUTABLE;
+		}
+		PendingIntent intent = PendingIntent.getActivity(this, 0,
+			notificationIntent, pendingIntentFlag);
+
+		builder.setContentTitle(getString(R.string.game_notification_title))
+			.setSmallIcon(R.mipmap.ic_launcher)
+			.setContentIntent(intent)
+			.setOngoing(true);
+
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			// This avoids a stuck notification if the app is killed while
+			// in-game: (1) if the user closes the app from the "Recents" screen
+			// or (2) if the system kills the app while it is in background.
+			// onStop is called too early to remove the notification and
+			// onDestroy is often not called at all, so there's this hack instead.
+			builder.setTimeoutAfter(16000);
+
+			// Replace the notification just before it expires as long as the app is
+			// running (and we're still in-game).
+			final Handler handler = new Handler(Looper.getMainLooper());
+			handler.postDelayed(new Runnable() {
+				@Override
+				public void run() {
+					if (gameNotificationShown) {
+						updateGameNotification();
+					}
+				}
+			}, 15000);
+		}
+
+		mNotifyManager.notify(MainActivity.NOTIFICATION_ID_GAME, builder.build());
+	}
+
+
+	public void setPlayingNowNotification(boolean show) {
+		gameNotificationShown = show;
+		updateGameNotification();
+	}
 }
diff --git a/android/app/src/main/java/net/minetest/minetest/MainActivity.java b/android/app/src/main/java/net/minetest/minetest/MainActivity.java
index 7735e8b3d06df..a2edb2d7a061c 100644
--- a/android/app/src/main/java/net/minetest/minetest/MainActivity.java
+++ b/android/app/src/main/java/net/minetest/minetest/MainActivity.java
@@ -43,6 +43,8 @@
 
 public class MainActivity extends AppCompatActivity {
 	public static final String NOTIFICATION_CHANNEL_ID = "Minetest channel";
+	public static final int NOTIFICATION_ID_UNZIP = 1;
+	public static final int NOTIFICATION_ID_GAME = 2;
 
 	private final static int versionCode = BuildConfig.VERSION_CODE;
 	private static final String SETTINGS = "MinetestSettings";
diff --git a/android/app/src/main/java/net/minetest/minetest/UnzipService.java b/android/app/src/main/java/net/minetest/minetest/UnzipService.java
index 542b2a1598ff5..895ad66fc3cf1 100644
--- a/android/app/src/main/java/net/minetest/minetest/UnzipService.java
+++ b/android/app/src/main/java/net/minetest/minetest/UnzipService.java
@@ -51,7 +51,6 @@ public class UnzipService extends IntentService {
 	public static final int SUCCESS = -1;
 	public static final int FAILURE = -2;
 	public static final int INDETERMINATE = -3;
-	private final int id = 1;
 	private NotificationManager mNotifyManager;
 	private boolean isSuccess = true;
 	private String failureMessage;
@@ -100,11 +99,14 @@ protected void onHandleIntent(Intent intent) {
 		}
 	}
 
+	// TODO: share code with GameActivity.updateGameNotification
 	@NonNull
 	private Notification.Builder createNotification() {
-		Notification.Builder builder;
-		if (mNotifyManager == null)
+		if (mNotifyManager == null) {
 			mNotifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+		}
+
+		Notification.Builder builder;
 		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
 			builder = new Notification.Builder(this, MainActivity.NOTIFICATION_CHANNEL_ID);
 		} else {
@@ -128,7 +130,7 @@ private Notification.Builder createNotification() {
 				.setOngoing(true)
 				.setProgress(0, 0, true);
 
-		mNotifyManager.notify(id, builder.build());
+		mNotifyManager.notify(MainActivity.NOTIFICATION_ID_UNZIP, builder.build());
 		return builder;
 	}
 
@@ -200,14 +202,14 @@ private void publishProgress(@Nullable Notification.Builder notificationBuilder,
 			} else {
 				notificationBuilder.setProgress(100, progress, false);
 			}
-			mNotifyManager.notify(id, notificationBuilder.build());
+			mNotifyManager.notify(MainActivity.NOTIFICATION_ID_UNZIP, notificationBuilder.build());
 		}
 	}
 
 	@Override
 	public void onDestroy() {
 		super.onDestroy();
-		mNotifyManager.cancel(id);
+		mNotifyManager.cancel(MainActivity.NOTIFICATION_ID_UNZIP);
 		publishProgress(null, R.string.loading, isSuccess ? SUCCESS : FAILURE);
 	}
 }
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 98511e72c9b8b..8b8bc625b551f 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -6,6 +6,7 @@
 	<string name="notification_channel_description">Notifications from Luanti</string>
 	<string name="unzip_notification_title">Loading Luanti</string>
 	<string name="unzip_notification_description">Less than 1 minute&#8230;</string>
+	<string name="game_notification_title">Luanti is running</string>
 	<string name="ime_dialog_done">Done</string>
 	<string name="no_web_browser">No web browser found</string>
 </resources>
diff --git a/src/client/game.cpp b/src/client/game.cpp
index 08be9c8092be4..42b605685c406 100644
--- a/src/client/game.cpp
+++ b/src/client/game.cpp
@@ -1021,6 +1021,10 @@ void Game::run()
 	const bool initial_window_maximized = !g_settings->getBool("fullscreen") &&
 			g_settings->getBool("window_maximized");
 
+#ifdef __ANDROID__
+	porting::setPlayingNowNotification(true);
+#endif
+
 	auto framemarker = FrameMarker("Game::run()-frame").started();
 
 	while (m_rendering_engine->run()
@@ -1103,6 +1107,10 @@ void Game::run()
 
 	framemarker.end();
 
+#ifdef __ANDROID__
+	porting::setPlayingNowNotification(false);
+#endif
+
 	RenderingEngine::autosaveScreensizeAndCo(initial_screen_size, initial_window_maximized);
 }
 
diff --git a/src/porting_android.cpp b/src/porting_android.cpp
index 620d9d22479f1..bc44a4e32d59e 100644
--- a/src/porting_android.cpp
+++ b/src/porting_android.cpp
@@ -187,6 +187,18 @@ void shareFileAndroid(const std::string &path)
 	jnienv->CallVoidMethod(activity, url_open, jurl);
 }
 
+void setPlayingNowNotification(bool show)
+{
+	jmethodID play_notification = jnienv->GetMethodID(activityClass,
+			"setPlayingNowNotification", "(Z)V");
+
+	FATAL_ERROR_IF(play_notification == nullptr,
+			"porting::setPlayingNowNotification unable to find Java setPlayingNowNotification method");
+
+	jboolean jshow = show;
+	jnienv->CallVoidMethod(activity, play_notification, jshow);
+}
+
 AndroidDialogType getLastInputDialogType()
 {
 	jmethodID lastdialogtype = jnienv->GetMethodID(activityClass,
diff --git a/src/porting_android.h b/src/porting_android.h
index 86601f450d8e5..6f69c918e028d 100644
--- a/src/porting_android.h
+++ b/src/porting_android.h
@@ -36,6 +36,13 @@ void showComboBoxDialog(const std::string *optionList, s32 listSize, s32 selecte
  */
 void shareFileAndroid(const std::string &path);
 
+/**
+ * Shows/hides notification that the game is running
+ *
+ * @param show whether to show/hide the notification
+ */
+void setPlayingNowNotification(bool show);
+
 /*
  * Types of Android input dialog:
  * 1. Text input (single/multi-line text and password field)


Reviews:
user: login: sfan5
body: let's just merge this.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-25T18:28:25Z
user: login: grorp
body: wanted to have a look if we're doing this

I haven't tested etc.

thought: shouldn't this be shown while starting / shutting down too?
especially starting will be heavy on resources too, with media being downloaded over the network, textures being loaded, etc., so having an indicator that it's still happening in the background would be good there too.
(would probably just have to move the porting::setPlayingNowNotification calls)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T01:32:29Z
user: login: grorp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-12T14:15:55Z
user: login: grorp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-12T14:16:32Z
user: login: grorp
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-12T14:32:37Z
user: login: SmallJoker
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-20T19:15:28Z
user: login: grorp
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T01:38:40Z
user: login: sfan5
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-22T07:23:45Z
user: login: SmallJoker
body: "Luanti is running." now shows up on Android 6.1 and it no longer crashes. Fine by me.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T19:31:28Z

Review comments:
diff_hunk: @@ -263,4 +268,39 @@ public String getLanguage() {
 	public boolean hasPhysicalKeyboard() {
 		return getContext().getResources().getConfiguration().keyboard != Configuration.KEYBOARD_NOKEYS;
 	}
+
+	public void setPlayingNowNotification(boolean show) {
path: android/app/src/main/java/net/minetest/minetest/GameActivity.java
user: login: grorp
body: most of the code here is duplicated with UnzipService.java, would it be possible to share this somehow? there's a Utils.java file already at least
created_at: 2025-03-27T01:22:39Z
author_association: MEMBER
diff_hunk: @@ -1173,6 +1173,10 @@ void Game::run()
 
 	auto framemarker = FrameMarker("Game::run()-frame").started();
 
+#ifdef __ANDROID__
+	porting::setPlayingNowNotification(true);
+#endif
path: src/client/game.cpp
user: login: grorp
body: ```suggestion
#ifdef __ANDROID__
	porting::setPlayingNowNotification(true);
#endif

	auto framemarker = FrameMarker("Game::run()-frame").started();
```
created_at: 2025-03-27T01:23:40Z
author_association: MEMBER
diff_hunk: @@ -1173,6 +1173,10 @@ void Game::run()
 
 	auto framemarker = FrameMarker("Game::run()-frame").started();
 
+#ifdef __ANDROID__
+	porting::setPlayingNowNotification(true);
+#endif
path: src/client/game.cpp
user: login: grorp
body: 9a522fd5b0d29e81b6c939accb360094cac07338
created_at: 2025-04-12T14:15:55Z
author_association: MEMBER
diff_hunk: @@ -263,4 +268,39 @@ public String getLanguage() {
 	public boolean hasPhysicalKeyboard() {
 		return getContext().getResources().getConfiguration().keyboard != Configuration.KEYBOARD_NOKEYS;
 	}
+
+	public void setPlayingNowNotification(boolean show) {
path: android/app/src/main/java/net/minetest/minetest/GameActivity.java
user: login: grorp
body: d4cc5412251c858b33454af51ef4496ae506076c ...
created_at: 2025-04-12T14:16:32Z
author_association: MEMBER
diff_hunk: @@ -263,4 +271,64 @@ public String getLanguage() {
 	public boolean hasPhysicalKeyboard() {
 		return getContext().getResources().getConfiguration().keyboard != Configuration.KEYBOARD_NOKEYS;
 	}
+
+	// TODO: share code with UnzipService.createNotification
+	private void updateGameNotification() {
+		if (mNotifyManager == null) {
+			mNotifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+		}
+
+		if (!gameNotificationShown) {
+			mNotifyManager.cancel(MainActivity.NOTIFICATION_ID_GAME);
+			return;
+		}
+
+		Notification.Builder builder;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			builder = new Notification.Builder(this, MainActivity.NOTIFICATION_CHANNEL_ID);
+		} else {
+			builder = new Notification.Builder(this);
+		}
+
+		Intent notificationIntent = new Intent(this, GameActivity.class);
+		notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+			| Intent.FLAG_ACTIVITY_SINGLE_TOP);
+		int pendingIntentFlag = 0;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+			pendingIntentFlag = PendingIntent.FLAG_MUTABLE;
+		}
+		PendingIntent intent = PendingIntent.getActivity(this, 0,
+			notificationIntent, pendingIntentFlag);
+
+		builder.setContentTitle(getString(R.string.game_notification_title))
+			.setSmallIcon(R.mipmap.ic_launcher)
+			.setContentIntent(intent)
+			.setOngoing(true)
+			// This avoids a stuck notification if the app is killed while
+			// in-game: (1) if the user closes the app from the "Recents" screen
+			// or (2) if the system kills the app while it is in background.
+			// onStop is called too early to remove the notification and
+			// onDestroy is often not called at all, so there's this hack instead.
+			.setTimeoutAfter(16000);
path: android/app/src/main/java/net/minetest/minetest/GameActivity.java
user: login: SmallJoker
body: This call crashes Luanti on Android 6.1. Please check against the API level.
```
.217 17014 17055 I HwCust  : Constructor found for class android.app.HwCustNotificationImpl
.217 17014 17058 I         : send start state to pg
.219  5278  6000 I PhoneStateManager: wilhelm start, mPid: 17014
.233 17014 17055 F art     : art/runtime/thread.cc:1326] No pending exception expected: java.lang.NoSuchMethodError: No virtual method setTimeoutAfter(J)Landroid/app/Notification$Builder; in class Landroid/app/Notification$Builder; or its super classes (declaration of 'android.app.Notification$Builder' appears in /system/framework/framework.jar)
.233 17014 17055 F art     : art/runtime/thread.cc:1326]   at android.app.Notification$Builder org.libsdl.app.SDLSurface$$ExternalSyntheticApiModelOutline0.m(android.app.Notification$Builder, long) (D8$$SyntheticClass:0)
.233 17014 17055 F art     : art/runtime/thread.cc:1326]   at void net.minetest.minetest.GameActivity.updateGameNotification() (GameActivity.java:312)
.233 17014 17055 F art     : art/runtime/thread.cc:1326]   at void net.minetest.minetest.GameActivity.setPlayingNowNotification(boolean) (GameActivity.java:332)
.233 17014 17055 F art     : art/runtime/thread.cc:1326]   at int org.libsdl.app.SDLActivity.nativeRunMain(java.lang.String, java.lang.String, java.lang.Object) (SDLActivity.java:-2)
.233 17014 17055 F art     : art/runtime/thread.cc:1326]   at void org.libsdl.app.SDLMain.run() (SDLActivity.java:1892)
.234 17014 17055 F art     : art/runtime/thread.cc:1326]   at void java.lang.Thread.run() (Thread.java:833)
.234 17014 17055 F art     : art/runtime/thread.cc:1326] 
.234 17014 17055 F art     : art/runtime/runtime.cc:368] Runtime aborting...
.234 17014 17055 F art     : art/runtime/runtime.cc:368] 
.235  5278  5292 I PgedBinderListener: kstate callback type:8 value1=17014 value2=KILLED
```
Used the command line: (with active USB debugging on the phone side)
```
adb logcat | grep -F "`adb shell ps | grep net.minetest.minetest | tr -s [:space:] ' ' | cut -d' ' -f2`"
```
created_at: 2025-04-20T19:15:23Z
author_association: MEMBER
diff_hunk: @@ -263,4 +271,64 @@ public String getLanguage() {
 	public boolean hasPhysicalKeyboard() {
 		return getContext().getResources().getConfiguration().keyboard != Configuration.KEYBOARD_NOKEYS;
 	}
+
+	// TODO: share code with UnzipService.createNotification
+	private void updateGameNotification() {
+		if (mNotifyManager == null) {
+			mNotifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+		}
+
+		if (!gameNotificationShown) {
+			mNotifyManager.cancel(MainActivity.NOTIFICATION_ID_GAME);
+			return;
+		}
+
+		Notification.Builder builder;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			builder = new Notification.Builder(this, MainActivity.NOTIFICATION_CHANNEL_ID);
+		} else {
+			builder = new Notification.Builder(this);
+		}
+
+		Intent notificationIntent = new Intent(this, GameActivity.class);
+		notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+			| Intent.FLAG_ACTIVITY_SINGLE_TOP);
+		int pendingIntentFlag = 0;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+			pendingIntentFlag = PendingIntent.FLAG_MUTABLE;
+		}
+		PendingIntent intent = PendingIntent.getActivity(this, 0,
+			notificationIntent, pendingIntentFlag);
+
+		builder.setContentTitle(getString(R.string.game_notification_title))
+			.setSmallIcon(R.mipmap.ic_launcher)
+			.setContentIntent(intent)
+			.setOngoing(true)
+			// This avoids a stuck notification if the app is killed while
+			// in-game: (1) if the user closes the app from the "Recents" screen
+			// or (2) if the system kills the app while it is in background.
+			// onStop is called too early to remove the notification and
+			// onDestroy is often not called at all, so there's this hack instead.
+			.setTimeoutAfter(16000);
path: android/app/src/main/java/net/minetest/minetest/GameActivity.java
user: login: grorp
body: I'm not sure how I missed that, good catch! Should be fixed as of 48f66630ba19aa8c14fbfa6d7125e274e9362472
created_at: 2025-04-22T01:38:39Z
author_association: MEMBER
diff_hunk: @@ -263,4 +271,64 @@ public String getLanguage() {
 	public boolean hasPhysicalKeyboard() {
 		return getContext().getResources().getConfiguration().keyboard != Configuration.KEYBOARD_NOKEYS;
 	}
+
+	// TODO: share code with UnzipService.createNotification
+	private void updateGameNotification() {
+		if (mNotifyManager == null) {
+			mNotifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
+		}
+
+		if (!gameNotificationShown) {
+			mNotifyManager.cancel(MainActivity.NOTIFICATION_ID_GAME);
+			return;
+		}
+
+		Notification.Builder builder;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+			builder = new Notification.Builder(this, MainActivity.NOTIFICATION_CHANNEL_ID);
+		} else {
+			builder = new Notification.Builder(this);
+		}
+
+		Intent notificationIntent = new Intent(this, GameActivity.class);
+		notificationIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP
+			| Intent.FLAG_ACTIVITY_SINGLE_TOP);
+		int pendingIntentFlag = 0;
+		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+			pendingIntentFlag = PendingIntent.FLAG_MUTABLE;
+		}
+		PendingIntent intent = PendingIntent.getActivity(this, 0,
+			notificationIntent, pendingIntentFlag);
+
+		builder.setContentTitle(getString(R.string.game_notification_title))
+			.setSmallIcon(R.mipmap.ic_launcher)
+			.setContentIntent(intent)
+			.setOngoing(true)
+			// This avoids a stuck notification if the app is killed while
+			// in-game: (1) if the user closes the app from the "Recents" screen
+			// or (2) if the system kills the app while it is in background.
+			// onStop is called too early to remove the notification and
+			// onDestroy is often not called at all, so there's this hack instead.
+			.setTimeoutAfter(16000);
path: android/app/src/main/java/net/minetest/minetest/GameActivity.java
user: login: sfan5
body: FWIW the usual way to avoid such pitfalls is to use the compat library, in this case [NotificationCompat](https://developer.android.com/reference/androidx/core/app/NotificationCompat)
created_at: 2025-04-22T07:23:44Z
author_association: MEMBER

PR comments:
user: login: Zughy
created_at: 2024-08-16T23:38:02Z
body: @srifqi rebase needed
author_association: CONTRIBUTOR
user: login: srifqi
created_at: 2024-08-20T00:00:22Z
body: I have rebased this PR to current `master`.
author_association: CONTRIBUTOR
user: login: Zughy
created_at: 2024-09-17T11:45:00Z
body: @srifqi another rebase
author_association: CONTRIBUTOR
user: login: srifqi
created_at: 2024-09-19T12:20:22Z
body: I have rebased this PR to current `master`. I also fixed some bugs after the previous rebase.
author_association: CONTRIBUTOR
user: login: maplemedley
created_at: 2025-03-08T14:53:47Z
body: Any specific holdups/issues with this? It'd be a really nice change

Maybe the notif staying issue in TODO can be fixed with a "quit" button in the notification, that saves and quits you needing to open tha app again
author_association: CONTRIBUTOR
user: login: grorp
created_at: 2025-04-12T14:23:48Z
body: > Known issue: The notification still exists after removing Minetest from the recent application list while the player is still inside a world/server. Restarting Minetest (or via tapping the notification) will clear it up. This issue also exists in other application and seems to be OEM-dependent.

After testing, this seemed annoying enough to need fixing (if there's an "app is running" notification, it should not be shown if the app isn't running, especially if the user explicitly killed the app), so I implemented this hack after reading some Stack Overflow answers (and Android docs): c992035a6e99569e45be946c2706512bbf20c74b

I hope that's acceptable, it works as intended on my device, but might profit from more testing with other Android versions/devices.
author_association: MEMBER
user: login: SmallJoker
created_at: 2025-04-20T18:57:28Z
body: Please wait a moment. I need to figure out whether this PR causes a crash on my Android 6.1 or whether it's already caused by master.
author_association: MEMBER
user: login: grorp
created_at: 2025-04-22T01:46:53Z
body: (Rebased (not because of conflicts, just for convenience))
author_association: MEMBER
user: login: srifqi
created_at: 2025-05-02T10:00:04Z
body: Thank you so much for all the works on this PR!
author_association: CONTRIBUTOR