Skip to content

Commit

Permalink
app, cmd/gogio: add android foreground permission and service
Browse files Browse the repository at this point in the history
This adds the permission android.permission.FOREGROUND_SERVICE and adds
GioForegroundService which creates the tray Notification necessary to
implement the Foreground Service.

This adds the method Start to package app, which 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.

See https://developer.android.com/guide/components/foreground-services
and https://developer.android.com/training/notify-user/build-notification

Signed-off-by: Masala <[email protected]>
  • Loading branch information
mixmasala committed Sep 27, 2023
1 parent 313c488 commit a1625b7
Show file tree
Hide file tree
Showing 6 changed files with 214 additions and 3 deletions.
10 changes: 10 additions & 0 deletions app/Gio.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import android.content.ClipboardManager;
import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;

Expand Down Expand Up @@ -65,4 +66,13 @@ 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);
ctx.startService(intent);
return intent;
}
}
108 changes: 108 additions & 0 deletions app/GioForegroundService.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
9 changes: 9 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,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
Expand Down
66 changes: 63 additions & 3 deletions app/os_android.go
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -225,9 +229,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 {
Expand Down Expand Up @@ -428,6 +434,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)
Expand Down Expand Up @@ -1455,3 +1465,53 @@ func Java_org_gioui_Gio_scheduleMainFuncs(env *C.JNIEnv, cls C.jclass) {
}

func (_ ViewEvent) ImplementsEvent() {}

var foregroundService struct {
intent C.jobject
mu sync.Mutex
stop map[*int]bool
}

// startForeground starts the foreground service on android
func startForeground(title, text string) (stop func(), err error) {
foregroundService.mu.Lock()
defer foregroundService.mu.Unlock()
if len(foregroundService.stop) == 0 {
runInJVM(javaVM(), func(env *C.JNIEnv) {
foregroundService.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, foregroundService.intent)

} else {
panic(err)
}
})
}
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)
if len(foregroundService.stop) == 0 {
runInJVM(javaVM(), func(env *C.JNIEnv) {
defer C.jni_DeleteGlobalRef(env, foregroundService.intent)
callVoidMethod(env, android.appCtx, android.stopService, jvalue(foregroundService.intent))
})
}
}, nil

}

func init() {
foregroundService.stop = make(map[*int]bool)
}
11 changes: 11 additions & 0 deletions app/os_nandroid.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions app/permission/foreground/main.go
Original file line number Diff line number Diff line change
@@ -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:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
*/

package foreground

0 comments on commit a1625b7

Please sign in to comment.