Skip to content

Commit e9ee66b

Browse files
dbnicholsonrnixxzworkb
authored andcommitted
Support WorkManager tasks
The androidx WorkManager component allows simple handling of tasks in Android apps. Furthermore, in Android 12, WorkManager tasks are the preferred way to run foreground services when the app is in the background. Since the python interpreter integration only allows running one python interpreter per process, this uses a `RemoteListenableWorker` to run the tasks on a service in a separate process. Unfortunately, that's only available since the work-multiprocess 2.6.0 release, which requires SDK 30. Co-authored-by: Robert Niederreiter <[email protected]> Co-authored-by: Philipp Auersperg <[email protected]>
1 parent 114d626 commit e9ee66b

File tree

10 files changed

+325
-1
lines changed

10 files changed

+325
-1
lines changed

pythonforandroid/bootstraps/common/build/build.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,39 @@ def make_package(args):
494494
base_service_class=base_service_class,
495495
)
496496

497+
worker_names = []
498+
for spec in args.workers:
499+
spec = spec.split(':')
500+
name = spec[0]
501+
entrypoint = spec[1]
502+
503+
worker_names.append(name)
504+
worker_target_path = \
505+
'src/main/java/{}/{}Worker.java'.format(
506+
args.package.replace(".", "/"),
507+
name.capitalize()
508+
)
509+
render(
510+
'Worker.tmpl.java',
511+
worker_target_path,
512+
name=name,
513+
entrypoint=entrypoint,
514+
args=args,
515+
)
516+
517+
worker_service_target_path = \
518+
'src/main/java/{}/{}WorkerService.java'.format(
519+
args.package.replace(".", "/"),
520+
name.capitalize()
521+
)
522+
render(
523+
'WorkerService.tmpl.java',
524+
worker_service_target_path,
525+
name=name,
526+
entrypoint=entrypoint,
527+
args=args,
528+
)
529+
497530
# Find the SDK directory and target API
498531
with open('project.properties', 'r') as fileh:
499532
target = fileh.read().strip()
@@ -510,6 +543,15 @@ def make_package(args):
510543
sdk_dir = fileh.read().strip()
511544
sdk_dir = sdk_dir[8:]
512545

546+
# Specific WorkManager versions require newer SDK versions.
547+
#
548+
# See https://developer.android.com/jetpack/androidx/releases/work
549+
# for details.
550+
if int(android_api) >= 31:
551+
work_manager_version = '2.7.1'
552+
else:
553+
work_manager_version = '2.6.0'
554+
513555
# Try to build with the newest available build tools
514556
ignored = {".DS_Store", ".ds_store"}
515557
build_tools_versions = [x for x in listdir(join(sdk_dir, 'build-tools')) if x not in ignored]
@@ -541,6 +583,7 @@ def make_package(args):
541583
"args": args,
542584
"service": service,
543585
"service_names": service_names,
586+
"worker_names": worker_names,
544587
"android_api": android_api,
545588
"debug": "debug" in args.build_mode,
546589
"native_services": args.native_services
@@ -572,6 +615,7 @@ def make_package(args):
572615
build_tools_version=build_tools_version,
573616
debug_build="debug" in args.build_mode,
574617
is_library=(get_bootstrap_name() == 'service_library'),
618+
work_manager_version=work_manager_version,
575619
)
576620

577621
# gradle properties
@@ -797,6 +841,9 @@ def create_argument_parser():
797841
ap.add_argument('--service', dest='services', action='append', default=[],
798842
help='Declare a new service entrypoint: '
799843
'NAME:PATH_TO_PY[:foreground]')
844+
ap.add_argument('--worker', dest='workers', action='append', default=[],
845+
help='Declare a new worker entrypoint: '
846+
'NAME:PATH_TO_PY')
800847
ap.add_argument('--native-service', dest='native_services', action='append', default=[],
801848
help='Declare a new native service: '
802849
'package.name.service')
@@ -1048,6 +1095,11 @@ def _read_configuration():
10481095
'--launcher (SDL2 bootstrap only)' +
10491096
'to have something to launch inside the .apk!')
10501097
sys.exit(1)
1098+
1099+
if args.workers and not args.enable_androidx:
1100+
print('WARNING: Enabling androidx for worker support')
1101+
args.enable_androidx = True
1102+
10511103
make_package(args)
10521104

10531105
return args

pythonforandroid/bootstraps/common/build/jni/application/src/start.c

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,29 @@ JNIEXPORT int JNICALL Java_org_kivy_android_PythonService_nativeStart(
422422
true);
423423
}
424424

425+
JNIEXPORT int JNICALL Java_org_kivy_android_PythonWorker_nativeStart(
426+
JNIEnv *env,
427+
jobject thiz,
428+
jstring j_android_private,
429+
jstring j_android_argument,
430+
jstring j_service_entrypoint,
431+
jstring j_python_name,
432+
jstring j_python_home,
433+
jstring j_python_path,
434+
jstring j_arg) {
435+
LOGP("Entering org.kivy.android.PythonWorker.nativeStart");
436+
return native_service_start(env,
437+
thiz,
438+
j_android_private,
439+
j_android_argument,
440+
j_service_entrypoint,
441+
j_python_name,
442+
j_python_home,
443+
j_python_path,
444+
j_arg,
445+
false);
446+
}
447+
425448
#if defined(BOOTSTRAP_NAME_WEBVIEW) || defined(BOOTSTRAP_NAME_SERVICEONLY)
426449
// Webview and service_only uses some more functions:
427450

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package org.kivy.android;
2+
3+
import android.content.Context;
4+
import android.util.Log;
5+
6+
import androidx.annotation.NonNull;
7+
import androidx.concurrent.futures.CallbackToFutureAdapter.Completer;
8+
import androidx.concurrent.futures.CallbackToFutureAdapter;
9+
import androidx.work.ListenableWorker.Result;
10+
import androidx.work.multiprocess.RemoteListenableWorker;
11+
import androidx.work.Worker;
12+
import androidx.work.WorkerParameters;
13+
14+
import com.google.common.util.concurrent.ListenableFuture;
15+
16+
import java.io.File;
17+
import java.lang.System;
18+
import java.util.concurrent.Executors;
19+
20+
import org.kivy.android.PythonUtil;
21+
22+
public class PythonWorker extends RemoteListenableWorker {
23+
private static final String TAG = "PythonWorker";
24+
25+
// WorkRequest data key for python service argument
26+
public static final String ARGUMENT_SERVICE_ARGUMENT = "PYTHON_SERVICE_ARGUMENT";
27+
28+
// Python environment variables
29+
private String androidPrivate;
30+
private String androidArgument;
31+
private String pythonName;
32+
private String pythonHome;
33+
private String pythonPath;
34+
private String workerEntrypoint;
35+
36+
public PythonWorker(
37+
@NonNull Context context,
38+
@NonNull WorkerParameters params) {
39+
super(context, params);
40+
41+
String appRoot = PythonUtil.getAppRoot(context);
42+
43+
androidPrivate = appRoot;
44+
androidArgument = appRoot;
45+
pythonHome = appRoot;
46+
pythonPath = appRoot + ":" + appRoot + "/lib";
47+
48+
File appRootFile = new File(appRoot);
49+
PythonUtil.unpackAsset(context, "private", appRootFile, false);
50+
PythonUtil.loadLibraries(
51+
appRootFile,
52+
new File(getApplicationContext().getApplicationInfo().nativeLibraryDir)
53+
);
54+
}
55+
56+
public void setPythonName(String value) {
57+
pythonName = value;
58+
}
59+
60+
public void setWorkerEntrypoint(String value) {
61+
workerEntrypoint = value;
62+
}
63+
64+
@Override
65+
public ListenableFuture<Result> startRemoteWork() {
66+
return CallbackToFutureAdapter.getFuture(completer -> {
67+
String dataArg = getInputData().getString(ARGUMENT_SERVICE_ARGUMENT);
68+
final String serviceArg;
69+
if (dataArg != null) {
70+
Log.d(TAG, "Setting python service argument to " + dataArg);
71+
serviceArg = dataArg;
72+
} else {
73+
serviceArg = "";
74+
}
75+
76+
// If the work is cancelled, exit the whole process since we
77+
// have no other way to stop the python thread.
78+
//
79+
// FIXME: Unfortunately, exiting here causes the service to
80+
// behave unreliably since all the connections are not
81+
// unbound. Android will immediately restart the service to
82+
// bind the connection again and eventually there are issues
83+
// with the process not exiting to completely clear the
84+
// Python environment.
85+
completer.addCancellationListener(new Runnable() {
86+
@Override
87+
public void run() {
88+
Log.i(TAG, "Exiting remote work service process");
89+
System.exit(0);
90+
}
91+
}, Executors.newSingleThreadExecutor());
92+
93+
// The python thread handling the work needs to be run in a
94+
// separate thread so that future can be returned. Without
95+
// it, any cancellation can't be processed.
96+
final Thread pythonThread = new Thread(new Runnable() {
97+
@Override
98+
public void run() {
99+
int res = nativeStart(
100+
androidPrivate, androidArgument,
101+
workerEntrypoint, pythonName,
102+
pythonHome, pythonPath,
103+
serviceArg
104+
);
105+
Log.d(TAG, "Finished remote python work: " + res);
106+
107+
if (res == 0) {
108+
completer.set(Result.success());
109+
} else {
110+
completer.set(Result.failure());
111+
}
112+
}
113+
});
114+
pythonThread.setName("python_worker_thread");
115+
116+
Log.i(TAG, "Starting remote python work");
117+
pythonThread.start();
118+
119+
return TAG + " work thread";
120+
});
121+
}
122+
123+
// Native part
124+
public static native int nativeStart(
125+
String androidPrivate, String androidArgument,
126+
String workerEntrypoint, String pythonName,
127+
String pythonHome, String pythonPath,
128+
String pythonServiceArgument
129+
);
130+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package {{ args.package }};
2+
3+
import android.content.Context;
4+
import android.util.Log;
5+
6+
import androidx.annotation.NonNull;
7+
import androidx.work.Data;
8+
import androidx.work.WorkRequest;
9+
import androidx.work.WorkerParameters;
10+
11+
import org.kivy.android.PythonWorker;
12+
13+
public class {{ name|capitalize }}Worker extends PythonWorker {
14+
private static final String TAG = "{{ name|capitalize }}Worker";
15+
16+
public static {{ name|capitalize }}Worker mWorker = null;
17+
18+
public {{ name|capitalize }}Worker (
19+
@NonNull Context context,
20+
@NonNull WorkerParameters params) {
21+
super(context, params);
22+
setPythonName("{{ name }}");
23+
setWorkerEntrypoint("{{ entrypoint }}");
24+
mWorker = this;
25+
}
26+
27+
public static Data buildInputData (String serviceArgument) {
28+
String dataArgument = serviceArgument == null ? "" : serviceArgument;
29+
Data data = new Data.Builder()
30+
.putString(ARGUMENT_SERVICE_ARGUMENT, dataArgument)
31+
.putString(ARGUMENT_PACKAGE_NAME, "{{ args.package }}")
32+
.putString(ARGUMENT_CLASS_NAME,
33+
{{ name|capitalize }}WorkerService.class.getName())
34+
.build();
35+
Log.v(TAG, "Request data: " + data.toString());
36+
return data;
37+
}
38+
39+
public static WorkRequest buildWorkRequest (
40+
WorkRequest.Builder builder,
41+
String serviceArgument) {
42+
Data data = buildInputData(serviceArgument);
43+
return builder.setInputData(data).build();
44+
}
45+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package {{ args.package }};
2+
3+
import android.content.Context;
4+
import android.util.Log;
5+
6+
import androidx.work.Configuration;
7+
import androidx.work.multiprocess.RemoteWorkerService;
8+
import androidx.work.WorkManager;
9+
10+
import java.lang.System;
11+
12+
public class {{ name|capitalize }}WorkerService extends RemoteWorkerService {
13+
private static final String TAG = "{{ name|capitalize }}WorkerService";
14+
15+
@Override
16+
public void onCreate() {
17+
try {
18+
Log.v(TAG, "Initializing WorkManager");
19+
Context context = getApplicationContext();
20+
Configuration configuration = new Configuration.Builder()
21+
.setDefaultProcessName(context.getPackageName())
22+
.build();
23+
WorkManager.initialize(context, configuration);
24+
} catch (IllegalStateException e) {
25+
}
26+
super.onCreate();
27+
}
28+
29+
@Override
30+
public void onDestroy() {
31+
super.onDestroy();
32+
33+
// The process needs to exit when the service is destroyed since
34+
// p4a doesn't support starting a Python interpreter more than
35+
// once per process. Combined with the stopWithTask="true"
36+
// configuration in the manifest, this should ensure that the
37+
// service process exits when a task completes.
38+
Log.v(TAG, "Exiting service process");
39+
System.exit(0);
40+
}
41+
}

pythonforandroid/bootstraps/common/build/templates/build.tmpl.gradle

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ android {
101101
main {
102102
jniLibs.srcDir 'libs'
103103
java {
104+
{% if not args.enable_androidx %}
105+
exclude 'org/kivy/android/PythonWorker.java'
106+
{% endif %}
104107

105108
{%- for adir, pattern in args.extra_source_dirs -%}
106109
srcDir '{{adir}}'
@@ -128,10 +131,16 @@ dependencies {
128131
implementation '{{ depend }}'
129132
{%- endfor %}
130133
{%- endif %}
131-
{% if args.presplash_lottie %}
134+
{%- if args.presplash_lottie %}
132135
implementation 'com.airbnb.android:lottie:3.4.0'
133136
{%- endif %}
134137
implementation 'com.android.support:support-v4:26.1.0'
135138
implementation 'com.android.support:multidex:1.0.3'
139+
{%- if args.workers %}
140+
implementation 'androidx.annotation:annotation:1.3.0'
141+
implementation 'androidx.concurrent:concurrent-futures:1.1.0'
142+
implementation 'androidx.work:work-runtime:{{ work_manager_version }}'
143+
implementation 'androidx.work:work-multiprocess:{{ work_manager_version }}'
144+
{%- endif %}
136145
}
137146

pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@
119119
{% for name in native_services %}
120120
<service android:name="{{ name }}" />
121121
{% endfor %}
122+
{% for name in worker_names %}
123+
<service android:name="{{ args.package }}.{{ name|capitalize }}WorkerService"
124+
android:process=":worker_{{ name }}"
125+
android:exported="false"
126+
android:stopWithTask="true" />
127+
{% endfor %}
122128

123129
{% if args.billing_pubkey %}
124130
<service android:name="org.kivy.android.billing.BillingReceiver"

pythonforandroid/bootstraps/service_library/build/templates/AndroidManifest.tmpl.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
android:process=":service_{{ name }}"
1414
android:exported="true" />
1515
{% endfor %}
16+
{% for name in worker_names %}
17+
<service android:name="{{ args.package }}.{{ name|capitalize }}WorkerService"
18+
android:process=":worker_{{ name }}"
19+
android:exported="false"
20+
android:stopWithTask="true" />
21+
{% endfor %}
1622
</application>
1723

1824
</manifest>

0 commit comments

Comments
 (0)