diff --git a/app/Gio.java b/app/Gio.java index 33e1a68b6..4383aef27 100644 --- a/app/Gio.java +++ b/app/Gio.java @@ -5,6 +5,8 @@ import android.content.ClipboardManager; import android.content.ClipData; import android.content.Context; +import android.content.Intent; +import android.os.Build; import android.os.Handler; import android.os.Looper; @@ -65,4 +67,21 @@ static void wakeupMainThread() { } static private native void scheduleMainFuncs(); + + static Intent startForegroundService(Context ctx, String title, String text) throws ClassNotFoundException { + Intent intent = new Intent(); + intent.setClass(ctx, ctx.getClassLoader().loadClass("org/gioui/GioForegroundService")); + intent.putExtra("title", title); + intent.putExtra("text", text); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Use startForegroundService for API level 26 and later + ctx.startForegroundService(intent); + } else { + // Use startService for API level 25 and earlier + ctx.startService(intent); + } + + return intent; + } } diff --git a/app/GioForegroundService.java b/app/GioForegroundService.java new file mode 100644 index 000000000..c959e77ef --- /dev/null +++ b/app/GioForegroundService.java @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package org.gioui; +import android.app.Notification; +import android.app.Service; +import android.app.Notification; +import android.app.Notification.Builder; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.ComponentName; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.IBinder; +import android.os.Build; +import android.os.Bundle; + +// GioForegroundService implements a Service required to use the FOREGROUND_SERVICE +// permission on Android, in order to run an application in the background. +// See https://developer.android.com/guide/components/foreground-services for +// more details. To add this permission to your application, import +// gioui.org/app/permission/foreground and use the Start method from that +// package to control this service. +public class GioForegroundService extends Service { + private String channelName; + + // ForegroundNotificationID is a default unique ID for the tray Notification of this service, as it must be nonzero. + private int ForegroundNotificationID = 0x42424242; + + @Override public int onStartCommand(Intent intent, int flags, int startId) { + // Get the channel parameters from intent extras and package metadata. + Bundle extras = intent.getExtras(); + String title = extras.getString("title"); + String text = extras.getString("text"); + Context ctx = getApplicationContext(); + try { + ComponentName svc = new ComponentName(this, this.getClass()); + Bundle metadata = getPackageManager().getServiceInfo(svc, PackageManager.GET_META_DATA).metaData; + if (metadata == null) { + throw new RuntimeException("No ForegroundService MetaData found"); + } + channelName = metadata.getString("org.gioui.ForegroundChannelName"); + String channelDesc = metadata.getString("org.gioui.ForegroundChannelDesc", ""); + String channelID = metadata.getString("org.gioui.ForegroundChannelID"); + int notificationID = metadata.getInt("org.gioui.ForegroundNotificationID", ForegroundNotificationID); + this.createNotificationChannel(channelDesc, channelID, channelName); + Intent launchIntent = getPackageManager().getLaunchIntentForPackage(ctx.getPackageName()); + + PendingIntent pending = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + pending = PendingIntent.getActivity(ctx, notificationID, launchIntent, Intent.FLAG_ACTIVITY_CLEAR_TASK|PendingIntent.FLAG_IMMUTABLE); + } else { + pending = PendingIntent.getActivity(ctx, notificationID, launchIntent, Intent.FLAG_ACTIVITY_CLEAR_TASK); + } + Notification.Builder builder = new Notification.Builder(ctx, channelID) + .setContentTitle(title) + .setContentText(text) + .setSmallIcon(getResources().getIdentifier("@mipmap/ic_launcher_adaptive", "drawable", getPackageName())) + .setContentIntent(pending) + .setPriority(Notification.PRIORITY_MIN); + startForeground(notificationID, builder.build()); + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException(e); + } catch (java.lang.SecurityException e) { + // XXX: notify the caller of Start that the service has failed + throw new RuntimeException(e); + } + return START_NOT_STICKY; + } + + @Override public IBinder onBind(Intent intent) { + return null; + } + + @Override public void onCreate() { + super.onCreate(); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + super.onTaskRemoved(rootIntent); + this.deleteNotificationChannel(); + stopForeground(true); + this.stopSelf(); + } + + @Override public void onDestroy() { + this.deleteNotificationChannel(); + } + + private void deleteNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.deleteNotificationChannel(channelName); + } + } + + private void createNotificationChannel(String channelDesc, String channelID, String channelName) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // https://developer.android.com/training/notify-user/build-notification#java + NotificationChannel channel = new NotificationChannel(channelID, channelName, NotificationManager.IMPORTANCE_LOW); + channel.setDescription(channelDesc); + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } +} diff --git a/app/app.go b/app/app.go index ef4a0fe2e..5e3179bad 100644 --- a/app/app.go +++ b/app/app.go @@ -120,6 +120,15 @@ func DataDir() (string, error) { return dataDir() } +// Start starts the foreground service on android, notifies the system that the +// program will perform background work and that it shouldn't be killed. The +// foreground service is stopped using the cancel function returned by Start(). +// If multiple calls to Start are made, the foreground service will not be +// stopped until the final cancel function has been called. +func Start(title, text string) (stop func(), err error) { + return startForeground(title, text) +} + // Main must be called last from the program main function. // On most platforms Main blocks forever, for Android and // iOS it returns immediately to give control of the main diff --git a/app/os_android.go b/app/os_android.go index a18bd083a..ae15c37e8 100644 --- a/app/os_android.go +++ b/app/os_android.go @@ -40,6 +40,10 @@ static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) { return (*env)->GetObjectClass(env, obj); } +static jobject jni_NewObject(JNIEnv *env, jclass clazz, jmethodID methodID) { + return (*env)->NewObject(env, clazz, methodID); +} + static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) { return (*env)->GetMethodID(env, clazz, name, sig); } @@ -231,9 +235,11 @@ var android struct { // gioCls is the class of the Gio class. gioCls C.jclass - mwriteClipboard C.jmethodID - mreadClipboard C.jmethodID - mwakeupMainThread C.jmethodID + mwriteClipboard C.jmethodID + mreadClipboard C.jmethodID + mwakeupMainThread C.jmethodID + startForegroundService C.jmethodID + stopService C.jmethodID // android.view.accessibility.AccessibilityNodeInfo class. accessibilityNodeInfo struct { @@ -434,6 +440,10 @@ func initJVM(env *C.JNIEnv, gio C.jclass, ctx C.jobject) { android.mreadClipboard = getStaticMethodID(env, gio, "readClipboard", "(Landroid/content/Context;)Ljava/lang/String;") android.mwakeupMainThread = getStaticMethodID(env, gio, "wakeupMainThread", "()V") + cls = getObjectClass(env, android.appCtx) + android.stopService = getMethodID(env, cls, "stopService", "(Landroid/content/Intent;)Z") + android.startForegroundService = getStaticMethodID(env, gio, "startForegroundService", "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)Landroid/content/Intent;") + intern := func(s string) C.jstring { ref := C.jni_NewGlobalRef(env, C.jobject(javaString(env, s))) return C.jstring(ref) @@ -1495,3 +1505,56 @@ func Java_org_gioui_Gio_scheduleMainFuncs(env *C.JNIEnv, cls C.jclass) { func (AndroidViewEvent) implementsViewEvent() {} func (AndroidViewEvent) ImplementsEvent() {} + +var foregroundService struct { + intent C.jobject + mu sync.Mutex + stop map[*int]bool +} + +// startForeground starts the foreground service on android +// it returns a method that stops the foreground service, or an error +func startForeground(title, text string) (func(), error) { + foregroundService.mu.Lock() + defer foregroundService.mu.Unlock() + + var intent C.jobject + var err error + if len(foregroundService.stop) == 0 { + runInJVM(javaVM(), func(env *C.JNIEnv) { + intent, err = callStaticObjectMethod(env, android.gioCls, + android.startForegroundService, + jvalue(android.appCtx), + jvalue(javaString(env, title)), + jvalue(javaString(env, text)), + ) + if err == nil { + // get a reference across JNI sessions to the returned intent + foregroundService.intent = C.jni_NewGlobalRef(env, intent) + } + }) + } + if err != nil { + return nil, err + } + ref := new(int) + foregroundService.stop[ref] = true + return func() { + foregroundService.mu.Lock() + defer foregroundService.mu.Unlock() + delete(foregroundService.stop, ref) + // Each call to Start returns a stop() method; once the last stop() is called, stop the service. + if len(foregroundService.stop) == 0 { + runInJVM(javaVM(), func(env *C.JNIEnv) { + callVoidMethod(env, android.appCtx, android.stopService, jvalue(foregroundService.intent)) + C.jni_DeleteGlobalRef(env, foregroundService.intent) + foregroundService.intent = 0 + }) + } + }, nil + +} + +func init() { + foregroundService.stop = make(map[*int]bool) +} diff --git a/app/os_nandroid.go b/app/os_nandroid.go new file mode 100644 index 000000000..2f5b839d7 --- /dev/null +++ b/app/os_nandroid.go @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +//go:build !android +// +build !android + +package app + +// app.Start is a no-op on platforms other than android +func startForeground(title, text string) (stop func(), err error) { + return func() {}, nil +} diff --git a/app/permission/foreground/main.go b/app/permission/foreground/main.go new file mode 100644 index 000000000..9aef81636 --- /dev/null +++ b/app/permission/foreground/main.go @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +/* +Package foreground implements permissions to run a foreground service. +See https://developer.android.com/guide/components/foreground-services. + +The following entries will be added to AndroidManifest.xml: + + + +*/ + +package foreground