
Как запускать приложение или сервис написанные на python (Kivy) под Android (до API 25 версии) при запуске устройства. Что бы это сделать придется разбираться как работает buildozer и pythonforandroid. Т.к. на текущий момент сделать это по человечески не представляется возможным, из-за того что разработчики Kivy не позаботились об этом. Узнать что такое Kivy, как собрать первое приложение можно здесь или по ссылкам в конце статьи.
Мне помогли две статьи: Разработка игры под Android на Python на базе Kivy. От А до Я: подводные камни и неочевидные решения. Часть 1 и Android. Автозапуск приложения при загрузке: теория и практика. В первой автор не описал ключевые нюансы что, как, откуда и почему берется, а так же информация там частично устарела. Вторая дает понимание как работает механизм автозагрузки сервисов в Android. В сумме они помогли понять в какую сторону копать...
Разобравшись в работе определил два способа как сделать автозагрузку.
Неправильный
Что бы сервис программы загрузился после включения устройства нужно создать обработчик сигналов и обработать сигналы BOOT_COMPLETED или QUICKBOOT_POWERON, которые шлет Android после загрузки системы всем программам. Эти сигналы надо принять и обработать. Сигналы которые сможет принять приложение прописываются в файле AndroidManifest.xml, только при разработке на Kivy он не доступен в явном виде. И более того после каждой сборки проекта он генерируется заново.
buildozer android debug
Поэтому пришлось поискать файл который берется за его основу. Это AndroidManifest.tmpl.xml
При первой сборке проекта, buildozer скачает python-for-android и разместит его в папке проекта (kivy_service_test):
./kivy_service_test/.buildozer/android/platform/python-for-android/
Соответственно AndroidManifest.tmpl.xml будет в:
./kivy_service_test/.buildozer/android/platform/python-for-android/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml
Он нам и нужен. Его содержимое:
<?xml version="1.0" encoding="utf-8"?> <!-- Replace org.libsdl.app with the identifier of your game below, e.g. com.gamemaker.game --> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="{{ args.package }}" android:versionCode="{{ args.numeric_version }}" android:versionName="{{ args.version }}" android:installLocation="auto"> <supports-screens android:smallScreens="true" android:normalScreens="true" android:largeScreens="true" android:anyDensity="true" {% if args.min_sdk_version >= 9 %} android:xlargeScreens="true" {% endif %} /> <!-- Android 2.3.3 --> <uses-sdk android:minSdkVersion="{{ args.min_sdk_version }}" android:targetSdkVersion="{{ android_api }}" /> <!-- OpenGL ES 2.0 --> <uses-feature android:glEsVersion="0x00020000" /> <!-- Allow writing to external storage --> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> {% for perm in args.permissions %} {% if '.' in perm %} <uses-permission android:name="{{ perm }}" /> {% else %} <uses-permission android:name="android.permission.{{ perm }}" /> {% endif %} {% endfor %} {% if args.wakelock %} <uses-permission android:name="android.permission.WAKE_LOCK" /> {% endif %} {% if args.billing_pubkey %} <uses-permission android:name="com.android.vending.BILLING" /> {% endif %} {{ args.extra_manifest_xml }} <!-- Create a Java class extending SDLActivity and place it in a directory under src matching the package, e.g. src/com/gamemaker/game/MyGame.java then replace "SDLActivity" with the name of your class (e.g. "MyGame") in the XML below. An example Java class can be found in README-android.txt --> <application android:label="@string/app_name" {% if debug %}android:debuggable="true"{% endif %} android:icon="@mipmap/icon" android:allowBackup="{{ args.allow_backup }}" {% if args.backup_rules %}android:fullBackupContent="@xml/{{ args.backup_rules }}"{% endif %} {{ args.extra_manifest_application_arguments }} android:theme="{{args.android_apptheme}}{% if not args.window %}.Fullscreen{% endif %}" android:hardwareAccelerated="true" android:extractNativeLibs="true" > {% for l in args.android_used_libs %} <uses-library android:name="{{ l }}" /> {% endfor %} {% for m in args.meta_data %} <meta-data android:name="{{ m.split('=', 1)[0] }}" android:value="{{ m.split('=', 1)[-1] }}"/>{% endfor %} <meta-data android:name="wakelock" android:value="{% if args.wakelock %}1{% else %}0{% endif %}"/> <activity android:name="{{args.android_entrypoint}}" android:label="@string/app_name" android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|fontScale|uiMode{% if args.min_sdk_version >= 8 %}|uiMode{% endif %}{% if args.min_sdk_version >= 13 %}|screenSize|smallestScreenSize{% endif %}{% if args.min_sdk_version >= 17 %}|layoutDirection{% endif %}{% if args.min_sdk_version >= 24 %}|density{% endif %}" android:screenOrientation="{{ args.orientation }}" android:exported="true" {% if args.activity_launch_mode %} android:launchMode="{{ args.activity_launch_mode }}" {% endif %} > {% if args.launcher %} <intent-filter> <action android:name="org.kivy.LAUNCH" /> <category android:name="android.intent.category.DEFAULT" /> <data android:scheme="{{ url_scheme }}" /> </intent-filter> {% else %} <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> {% endif %} {%- if args.intent_filters -%} {{- args.intent_filters -}} {%- endif -%} </activity> {% if args.launcher %} <activity android:name="org.kivy.android.launcher.ProjectChooser" android:icon="@mipmap/icon" android:label="@string/app_name" android:exported="true"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> {% endif %} {% if service or args.launcher %} <service android:name="{{ args.service_class_name }}" android:process=":pythonservice" /> {% endif %} {% for name in service_names %} <service android:name="{{ args.package }}.Service{{ name|capitalize }}" android:process=":service_{{ name }}" /> {% endfor %} {% for name in native_services %} <service android:name="{{ name }}" /> {% endfor %} {% if args.billing_pubkey %} <service android:name="org.kivy.android.billing.BillingReceiver" android:process=":pythonbilling" /> <receiver android:name="org.kivy.android.billing.BillingReceiver" android:process=":pythonbillingreceiver" android:exported="false"> <intent-filter> <action android:name="com.android.vending.billing.IN_APP_NOTIFY" /> <action android:name="com.android.vending.billing.RESPONSE_CODE" /> <action android:name="com.android.vending.billing.PURCHASE_STATE_CHANGED" /> </intent-filter> </receiver> {% endif %} {% for a in args.add_activity %} <activity android:name="{{ a }}"></activity> {% endfor %} </application> </manifest>
Этот файл берется за основу создаваемого buildozer AndroidManifest.xml. При первом просмотре, сразу обратил внимания на такие вставки как например эта:
{{ args.extra_manifest_application_arguments }}
Их значения объясню дальше.
Когда этот файл был найден стало понятно, что делать. Правда на его поиск и понимание что искать ушло время.
Все возможные AndroidManifest-ы в папке проекта, их и анализировал.

Теперь нужно добавить внутрь тэга application наш тэг receiver в котором будет прописано имя нашего обработчика сигналов, и какие сигналы он принимает:
<receiver android:name=".MyBroadcastReceiver" android:enabled="true" android:exported="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" /> <action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.DELETE" /> </intent-filter> </receiver>
Выполняем:
buildozer android clean buildozer android debug
Если не сделать clean, то как оказалось за основу генерации берется не файл:
./kivy_service_test/.buildozer/android/platform/python-for-android/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml
а файл:
./kivy_service_test/.buildozer/android/platform/build-arm64-v8a/dists/kivy_service_test/templates/AndroidManifest.tmpl.xml
Который копируется туда при первой сборке:
buildozer android debug
И далее он не будет обновляться, пока не будет выполнена очистка проекта. Т.к. первая сборка не только скачивает python-for-android, она копирует исходные шаблонные файлы для сборки под каждую архитектуру для которой ведется сборка. В данном случае под arm64-v8a, которая указывается в buildozer.spec:
android.archs = arm64-v8a
Поэтому можно обновить только AndroidManifest.tmpl.xml, а не выполнять очистку проекта.
rm ./kivy_service_test/.buildozer/android/platform/build-arm64-v8a/dists/kivy_service_test/templates/AndroidManifest.tmpl.xml cp ./kivy_service_test/.buildozer/android/platform/python-for-android/pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml ./kivy_service_test/.buildozer/android/platform/build-arm64-v8a/dists/kivy_service_test/templates/
Правильный
С помощью файла buildozer.spec можно вносить некоторые правки в AndroidManifest.xml. Но вот ту, что нужна для автозагрузки нельзя. При анализе default.spec обнаружил следующие параметры настройки:
# (str) Extra xml to write directly inside the <manifest> element of AndroidManifest.xml # use that parameter to provide a filename from where to load your custom XML code android.extra_manifest_xml = ./src/android/extra_manifest.xml # (str) Extra xml to write directly inside the <manifest><application> tag of AndroidManifest.xml # use that parameter to provide a filename from where to load your custom XML arguments: android.extra_manifest_application_arguments = ./src/android/extra_manifest_application_arguments.xml
Теперь вернемся к вставке из AndroidManifest.tmpl.xml
{{ args.extra_manifest_application_arguments }}
Стало понятно куда будет подставлено содержимое файл��в xml из секции конфига. Оно автоматически обновится в AndroidManifest.xml при сборке.
python-for-android
Добавляем свою секцию в AndroidManifest.tmpl.xml в следующих файлах:
{{ args.extra_manifest_application_xml }}
- pythonforandroid/bootstraps/sdl2/build/templates/AndroidManifest.tmpl.xml
- pythonforandroid/bootstraps/service_library/build/templates/AndroidManifest.tmpl.xml
- pythonforandroid/bootstraps/service_only/build/templates/AndroidManifest.tmpl.xml
- pythonforandroid/bootstraps/webview/build/templates/AndroidManifest.tmpl.xml
Так же в python-for-android добавляем в файл pythonforandroid/bootstraps/common/build/build.py перед строкой ap.add_argument('--extra-manifest-application-arguments':
ap.add_argument('--extra-manifest-application-xml', default='', help='Extra xml to write directly inside the <application> element of' 'AndroidManifest.xml')
buildozer
Добавляем в файл buildozer/targets/android.py перед строкой # support for extra-manifest-application-arguments:
# support for extra-manifest-application extra_manifest_application_xml = self.buildozer.config.getdefault( 'app', 'android.extra_manifest_application_xml', '') if extra_manifest_application_xml: cmd.append('--extra-manifest-application-xml= {}'.format(open(extra_manifest_application_xml, 'rt').read().replace('"', '\\"')))
После этого в buildozer.spec доступна новая настройка:
android.extra_manifest_application_xml = %(source.dir)s/xml/receivers.xml
Которая в нужное место AndroidManifest.xml подставляет обработчик сигналов описанных в receivers.xml
Правки в исходниках: buildozer, python-for-android.
Мои pull request разработчики на текущий момент не одобрили, поэтому на рабочей машине правим следующие места:
- buildozer — /usr/local/lib/python3.8/dist-packages/buildozer (версия python индивидуальна)
- python-for-android — ./kivy_service_test/.buildozer/android/platform/python-for-android/
Receiver
receiver.xml
<receiver android:name=".MyBroadcastReceiver" android:enabled="true" android:exported="true"> <intent-filter> <action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" /> <action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.DELETE" /> </intent-filter> </receiver>
Его содержимое вставляется в AndroidManifest.xml
<application ...> <receiver> ... </receiver> </application>
MyBroadcastReceiver имя класса принимающего сигналы, он определен в MyBroadcastReceiver.java
package com.heattheatr.kivy_service_test; import android.content.BroadcastReceiver; import android.content.Intent; import android.content.Context; import org.kivy.android.PythonActivity; import java.lang.reflect.Method; import com.heattheatr.kivy_service_test.ServiceTest; public class MyBroadcastReceiver extends BroadcastReceiver { public MyBroadcastReceiver() { } // Запуск приложения. public void start_app(Context context, Intent intent) { Intent ix = new Intent(context, PythonActivity.class); ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(ix); } // Запуск сервиса. public void service_start(Context context, Intent intent) { String package_root = context.getFilesDir().getAbsolutePath(); String app_root = package_root + "/app"; Intent ix = new Intent(context, ServiceTest.class); ix.putExtra("androidPrivate", package_root); ix.putExtra("androidArgument", app_root); ix.putExtra("serviceEntrypoint", "service.py"); ix.putExtra("pythonName", "test"); ix.putExtra("pythonHome", app_root); ix.putExtra("pythonPath", package_root); ix.putExtra("serviceStartAsForeground", "true"); ix.putExtra("serviceTitle", "ServiceTest"); ix.putExtra("serviceDescription", "ServiceTest"); ix.putExtra("pythonServiceArgument", app_root + ":" + "/lib"); ix.putExtra("smallIconName", ""); ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startService(ix); } public void service_stop(Context context, Intent intent) { Intent intent_stop = new Intent(context, ServiceTest.class); context.stopService(intent_stop); } // Обработчик сигналов. public void onReceive(Context context, Intent intent) { switch (intent.getAction()) { case Intent.ACTION_BOOT_COMPLETED: System.out.println("python MyBroadcastReceiver.java MyBroadcastReceiver.class onReceive.method: ACTION_BOOT_COMPLETED"); this.service_start(context, intent); break; case Intent.ACTION_DELETE: System.out.println("python MyBroadcastReceiver.java MyBroadcastReceiver.class onReceive.method: ACTION_DELETE"); this.service_stop(context, intent); break; case Intent.ACTION_MAIN: System.out.println("python MyBroadcastReceiver.java MyBroadcastReceiver.class onReceive.method: ACTION_MAIN"); this.start_app(context, intent); break; default: break; } } }
Класс содержит четыре функции: запуск/остановка сервиса, запуск графического приложения и обработка сигналов (onReceive наследуемый метод от класса BroadcastReceiver). По какому сигналу что запускать на выбор разработчика.
Особую сложность у меня вызвала реализация метода service_start. Т.к. необходимые Intent для запуска сервиса были изменены. Актуальные нашел здесь PythonActivity.java, метод _do_start_service().
Service
Особо выделю ServiceTest, это класс нашего сервиса service.py. Приведенного из python к java.
#!/usr/bin/python3 #-*- coding: utf-8 -*- import os from time import sleep from kivy.utils import platform from jnius import cast from jnius import autoclass # Подключение классов Android if platform == 'android': PythonService = autoclass('org.kivy.android.PythonService') # Автоперезапуск упавшего сервиса PythonService.mService.setAutoRestartService(True) CurrentActivityService = cast("android.app.Service", PythonService.mService) ContextService = cast('android.content.Context', CurrentActivityService.getApplicationContext()) ContextWrapperService = cast('android.content.ContextWrapper', CurrentActivityService.getApplicationContext()) Manager = CurrentActivityService.getPackageManager() Intent = autoclass('android.content.Intent') def application_start(): pm = CurrentActivityService.getPackageManager() ix = pm.getLaunchIntentForPackage(CurrentActivityService.getPackageName()) ix.setAction(Intent.ACTION_VIEW) ix.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) CurrentActivityService.startActivity(ix) while True: print("python service running.....", CurrentActivityService.getPackageName(), os.getpid()) sleep(10) else: def application_start(): pass while True: print("python service running.....", os.getpid()) sleep(10)
Преобразовывается service.py в ServiceTest с помощью buildozer, в buildozer.spec задается настройка:
# NAME_SERVICE:PATH_TO_PY # (list) List of service to declare services = Test:./service.py:foreground
Согласно которой имя нашего файла сервиса будет Service + Test. Почему не Test?, а потому что так захотелось разработчикам. Они решили к любому имени добавлять префикс Service.
Путь до service.py нельзя задавать через %(source.dir)s, т.к. это будет путь до файла на компьютере, и соответственно на телефоне данный файл будет находиться по другому пути.
Настройка перезапуска сервиса в случае его завершения:
# Автоперезапуск упавшего сервиса PythonService.mService.setAutoRestartService(True)
Main
Так же сервис можно запускать/останавливать из графического приложения:
#!/usr/bin/python3 #-*- coding: utf-8 -*- import kivy kivy.require("2.1.0") from kivy.app import App from kivy.uix.button import Button from kivy.utils import platform import jnius from jnius import cast from jnius import autoclass # Подключение классов Android if platform == 'android': # Подключение класса System System = autoclass('java.lang.System') PythonActivity = autoclass('org.kivy.android.PythonActivity') CurrentActivity = cast('android.app.Activity', PythonActivity.mActivity) # Класс графики, который создает кнопку для выхода из приложения. class ButtonApp(App): def build(self): # use a (r, g, b, a) tuple btn = Button(text ="Push Me !", font_size ="20sp", background_color = (1, 1, 1, 1), color = (1, 1, 1, 1), size_hint = (.2, .1), pos_hint = {'x':.4, 'y':.45}) # bind() use to bind the button to function callback btn.bind(on_press = self.callback) return btn def on_start(self): self.service = None # При старте приложения запускаем сервис. self.service_start() # callback function tells when button pressed def callback(self, event): if platform == 'android': CurrentActivity.finishAndRemoveTask() System.exit(0) else : exit() # функция запуска сервиса def service_start(self): if platform == 'android': self.service = autoclass(CurrentActivity.getPackageName() + ".ServiceTest") self.service.start(CurrentActivity, "") # функция остановки сервиса def service_stop(self): if self.service : if platform == 'android': self.service.stop(CurrentActivity) ## # Старт. ## if __name__ == "__main__": # Отрисовка графики приложения ButtonApp().run()
В Android стоит защита которая не дает запустить сервис/приложение если они уже запущены, что упрощает жизнь. Что бы все заработало, необходимо после установки/обновления запустить новое приложение один раз. Т.к. в Android не будет запускать новоустановленное по сигналам в целях безопасности.
Отладка
После установки подключаемся к телефону:
adb logcat | egrep "python|Test|test"
И видим результат работы:
11-08 18:34:01.214 12305 12318 I Test : Android kivy bootstrap done. __name__ is __main__ 11-08 18:34:01.214 12305 12318 I python : AND: Ran string 11-08 18:34:01.214 12305 12318 I python : Run user program, change dir and execute entrypoint 11-08 18:34:01.630 12305 12318 I Test : [INFO ] [Logger ] Record log in /data/user/0/com.heattheatr.kivy_service_test/files/app/.kivy/logs/kivy_22-11-08_0.txt 11-08 18:34:01.631 12305 12318 I Test : [INFO ] [Kivy ] v2.1.0 11-08 18:34:01.632 12305 12318 I Test : [INFO ] [Kivy ] Installed at "/data/user/0/com.heattheatr.kivy_service_test/files/app/_python_bundle/site-packages/kivy/__init__.pyc" 11-08 18:34:01.633 12305 12318 I Test : [INFO ] [Python ] v3.9.9 (main, Nov 7 2022, 09:58:48) 11-08 18:34:01.633 12305 12318 I Test : [Clang 12.0.8 (https://android.googlesource.com/toolchain/llvm-project c935d99d 11-08 18:34:01.634 12305 12318 I Test : [INFO ] [Python ] Interpreter at "" 11-08 18:34:01.636 12305 12318 I Test : [INFO ] [Logger ] Purge log fired. Processing... 11-08 18:34:01.638 12305 12318 I Test : [INFO ] [Logger ] Purge finished! 11-08 18:34:04.514 12305 12318 I Test : python service running..... com.heattheatr.kivy_service_test 12305 11-08 18:34:14.524 12305 12318 I Test : python service running..... com.heattheatr.kivy_service_test 12305
Из другой консоли посылаем сигналы приложению:
adb shell
am broadcast -a android.intent.action.BOOT_COMPLETED com.heattheatr.kivy_service_test am broadcast -a android.intent.action.DELETE com.heattheatr.kivy_service_test am broadcast -a android.intent.action.MAIN com.heattheatr.kivy_service_test
Вопрос
То с чем не смог разобраться, и хочу спросить у знающих людей.
- Закрытие приложение приводит к тому что сервис тоже закрывается (обошел это костылями по автоматическому перезапуску). Как не закрывать сервис при закрытии приложения?
Ответ
- Знающие люди нашлись.
По умолчанию сервис запускается с флагом START_NOT_STICKY, его нужно исправить на START_STICKY.
Указываем в buildozer.spec:
services = Test:./service.py:foreground:sticky
Спасибо за внимание.
